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:
135
weed/worker/tasks/balance/balance_task.go
Normal file
135
weed/worker/tasks/balance/balance_task.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation/volume_move"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// BalanceTask implements the Task interface
|
||||
type BalanceTask struct {
|
||||
*base.BaseTask
|
||||
server string
|
||||
volumeID uint32
|
||||
collection string
|
||||
progress float64
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
// NewBalanceTask creates a new balance task instance
|
||||
func NewBalanceTask(id string, server string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *BalanceTask {
|
||||
return &BalanceTask{
|
||||
BaseTask: base.NewBaseTask(id, types.TaskTypeBalance),
|
||||
server: server,
|
||||
volumeID: volumeID,
|
||||
collection: collection,
|
||||
grpcDialOption: grpcDialOption,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute implements the Task interface
|
||||
func (t *BalanceTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return fmt.Errorf("task parameters are required")
|
||||
}
|
||||
|
||||
balanceParams := params.GetBalanceParams()
|
||||
if balanceParams == nil {
|
||||
return fmt.Errorf("balance parameters are required")
|
||||
}
|
||||
|
||||
// Get source and destination from unified arrays
|
||||
if len(params.Sources) == 0 {
|
||||
return fmt.Errorf("source is required for balance task")
|
||||
}
|
||||
if len(params.Targets) == 0 {
|
||||
return fmt.Errorf("target is required for balance task")
|
||||
}
|
||||
|
||||
sourceNode := params.Sources[0].Node
|
||||
destNode := params.Targets[0].Node
|
||||
|
||||
if sourceNode == "" {
|
||||
return fmt.Errorf("source node is required for balance task")
|
||||
}
|
||||
if destNode == "" {
|
||||
return fmt.Errorf("destination node is required for balance task")
|
||||
}
|
||||
|
||||
t.GetLogger().WithFields(map[string]interface{}{
|
||||
"volume_id": t.volumeID,
|
||||
"source": sourceNode,
|
||||
"destination": destNode,
|
||||
"collection": t.collection,
|
||||
}).Info("Starting balance task - moving volume")
|
||||
|
||||
// The move sequence — freeze the source, copy, tail, verify the target
|
||||
// matches the source before the destructive delete — is shared with the
|
||||
// shell's volume.move/volume.balance commands.
|
||||
mover := volume_move.NewMover(t.grpcDialOption)
|
||||
err := mover.LiveMoveVolume(ctx, needle.VolumeId(t.volumeID), pb.ServerAddress(sourceNode), pb.ServerAddress(destNode), volume_move.VolumeMoveOptions{
|
||||
IdleTimeout: 60 * time.Second,
|
||||
IoBytePerSecond: balanceParams.IoBytePerSecond,
|
||||
Progress: func(percent float64, stage string) {
|
||||
t.ReportProgress(percent)
|
||||
t.GetLogger().Info("move stage: %s", stage)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("move volume %d from %s to %s: %w", t.volumeID, sourceNode, destNode, err)
|
||||
}
|
||||
|
||||
glog.Infof("Balance task completed successfully: volume %d moved from %s to %s",
|
||||
t.volumeID, sourceNode, destNode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate implements the UnifiedTask interface
|
||||
func (t *BalanceTask) Validate(params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return fmt.Errorf("task parameters are required")
|
||||
}
|
||||
|
||||
balanceParams := params.GetBalanceParams()
|
||||
if balanceParams == nil {
|
||||
return fmt.Errorf("balance parameters are required")
|
||||
}
|
||||
|
||||
if params.VolumeId != t.volumeID {
|
||||
return fmt.Errorf("volume ID mismatch: expected %d, got %d", t.volumeID, params.VolumeId)
|
||||
}
|
||||
|
||||
// Validate that at least one source matches our server
|
||||
found := false
|
||||
for _, source := range params.Sources {
|
||||
if source.Node == t.server {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("no source matches expected server %s", t.server)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateTime implements the UnifiedTask interface
|
||||
func (t *BalanceTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
|
||||
// Basic estimate based on simulated steps
|
||||
return 14 * time.Second // Sum of all step durations
|
||||
}
|
||||
|
||||
// GetProgress returns current progress
|
||||
func (t *BalanceTask) GetProgress() float64 {
|
||||
return t.progress
|
||||
}
|
||||
204
weed/worker/tasks/balance/config.go
Normal file
204
weed/worker/tasks/balance/config.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
)
|
||||
|
||||
// Config extends BaseConfig with balance-specific settings
|
||||
type Config struct {
|
||||
base.BaseConfig
|
||||
ImbalanceThreshold float64 `json:"imbalance_threshold"`
|
||||
MinServerCount int `json:"min_server_count"`
|
||||
IoBytePerSecond int64 `json:"io_byte_per_second"`
|
||||
DataCenterFilter string `json:"-"` // per-detection-run, not persisted
|
||||
RackFilter string `json:"-"` // per-detection-run, not persisted
|
||||
NodeFilter string `json:"-"` // per-detection-run, not persisted
|
||||
}
|
||||
|
||||
// NewDefaultConfig creates a new default balance configuration
|
||||
func NewDefaultConfig() *Config {
|
||||
return &Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: true,
|
||||
ScanIntervalSeconds: 30 * 60, // 30 minutes
|
||||
MaxConcurrent: 1,
|
||||
},
|
||||
ImbalanceThreshold: 0.2, // 20%
|
||||
MinServerCount: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec returns the configuration schema for balance tasks
|
||||
func GetConfigSpec() base.ConfigSpec {
|
||||
return base.ConfigSpec{
|
||||
Fields: []*config.Field{
|
||||
{
|
||||
Name: "enabled",
|
||||
JSONName: "enabled",
|
||||
Type: config.FieldTypeBool,
|
||||
DefaultValue: true,
|
||||
Required: false,
|
||||
DisplayName: "Enable Balance Tasks",
|
||||
Description: "Whether balance tasks should be automatically created",
|
||||
HelpText: "Toggle this to enable or disable automatic balance task generation",
|
||||
InputType: "checkbox",
|
||||
CSSClasses: "form-check-input",
|
||||
},
|
||||
{
|
||||
Name: "scan_interval_seconds",
|
||||
JSONName: "scan_interval_seconds",
|
||||
Type: config.FieldTypeInterval,
|
||||
DefaultValue: 30 * 60,
|
||||
MinValue: 5 * 60,
|
||||
MaxValue: 2 * 60 * 60,
|
||||
Required: true,
|
||||
DisplayName: "Scan Interval",
|
||||
Description: "How often to scan for volume distribution imbalances",
|
||||
HelpText: "The system will check for volume distribution imbalances at this interval",
|
||||
Placeholder: "30",
|
||||
Unit: config.UnitMinutes,
|
||||
InputType: "interval",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "max_concurrent",
|
||||
JSONName: "max_concurrent",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 1,
|
||||
MinValue: 1,
|
||||
MaxValue: 3,
|
||||
Required: true,
|
||||
DisplayName: "Max Concurrent Tasks",
|
||||
Description: "Maximum number of balance tasks that can run simultaneously",
|
||||
HelpText: "Limits the number of balance operations running at the same time",
|
||||
Placeholder: "1 (default)",
|
||||
Unit: config.UnitCount,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "imbalance_threshold",
|
||||
JSONName: "imbalance_threshold",
|
||||
Type: config.FieldTypeFloat,
|
||||
DefaultValue: 0.2,
|
||||
MinValue: 0.05,
|
||||
MaxValue: 0.5,
|
||||
Required: true,
|
||||
DisplayName: "Imbalance Threshold",
|
||||
Description: "Minimum imbalance ratio to trigger balancing",
|
||||
HelpText: "Volume distribution imbalances above this threshold will trigger balancing",
|
||||
Placeholder: "0.20 (20%)",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "io_byte_per_second",
|
||||
JSONName: "io_byte_per_second",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 0,
|
||||
MinValue: 0,
|
||||
Required: false,
|
||||
DisplayName: "Move IO Limit (bytes/sec)",
|
||||
Description: "Limit each volume move's copy rate",
|
||||
HelpText: "0 falls back to each volume server's own maintenance rate (-maintenanceBytePerSecond)",
|
||||
Placeholder: "0 (server maintenance rate)",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "min_server_count",
|
||||
JSONName: "min_server_count",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 2,
|
||||
MinValue: 2,
|
||||
MaxValue: 10,
|
||||
Required: true,
|
||||
DisplayName: "Minimum Server Count",
|
||||
Description: "Minimum number of servers required for balancing",
|
||||
HelpText: "Balancing will only occur if there are at least this many servers",
|
||||
Placeholder: "2 (default)",
|
||||
Unit: config.UnitCount,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
|
||||
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
|
||||
return &worker_pb.TaskPolicy{
|
||||
Enabled: c.Enabled,
|
||||
MaxConcurrent: int32(c.MaxConcurrent),
|
||||
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
TaskConfig: &worker_pb.TaskPolicy_BalanceConfig{
|
||||
BalanceConfig: &worker_pb.BalanceTaskConfig{
|
||||
ImbalanceThreshold: float64(c.ImbalanceThreshold),
|
||||
MinServerCount: int32(c.MinServerCount),
|
||||
IoBytePerSecond: c.IoBytePerSecond,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
|
||||
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy is nil")
|
||||
}
|
||||
|
||||
// Set general TaskPolicy fields
|
||||
c.Enabled = policy.Enabled
|
||||
c.MaxConcurrent = int(policy.MaxConcurrent)
|
||||
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds) // Direct seconds-to-seconds mapping
|
||||
|
||||
// Set balance-specific fields from the task config
|
||||
if balanceConfig := policy.GetBalanceConfig(); balanceConfig != nil {
|
||||
c.ImbalanceThreshold = float64(balanceConfig.ImbalanceThreshold)
|
||||
c.MinServerCount = int(balanceConfig.MinServerCount)
|
||||
c.IoBytePerSecond = balanceConfig.IoBytePerSecond
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigFromPersistence loads configuration from the persistence layer if available
|
||||
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
|
||||
config := NewDefaultConfig()
|
||||
|
||||
// Try to load from persistence if available
|
||||
if persistence, ok := configPersistence.(interface {
|
||||
LoadBalanceTaskPolicy() (*worker_pb.TaskPolicy, error)
|
||||
}); ok {
|
||||
policy, err := persistence.LoadBalanceTaskPolicy()
|
||||
switch {
|
||||
case err != nil:
|
||||
glog.Warningf("Could not read the persisted balance configuration, falling back to defaults: %v", err)
|
||||
case policy == nil:
|
||||
glog.V(1).Infof("No balance configuration persisted yet, using defaults")
|
||||
default:
|
||||
if err := config.FromTaskPolicy(policy); err != nil {
|
||||
glog.Warningf("Could not apply the persisted balance configuration, falling back to defaults: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("Loaded balance configuration from persistence")
|
||||
return config
|
||||
}
|
||||
}
|
||||
} else if configPersistence != nil {
|
||||
// A store was handed in but does not expose the accessor, so the persisted
|
||||
// settings are silently ignored - always a wiring bug, never a normal state.
|
||||
glog.Warningf("%T cannot provide the persisted balance configuration: it has no LoadBalanceTaskPolicy() method, "+
|
||||
"so the compiled-in defaults are used and any saved balance settings are ignored", configPersistence)
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Using default balance configuration")
|
||||
return config
|
||||
}
|
||||
839
weed/worker/tasks/balance/detection.go
Normal file
839
weed/worker/tasks/balance/detection.go
Normal file
@@ -0,0 +1,839 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Detection implements the detection logic for balance tasks.
|
||||
// maxResults limits how many balance operations are returned per invocation.
|
||||
// A non-positive maxResults means no explicit limit (uses a large default).
|
||||
// The returned truncated flag is true when detection stopped because it hit
|
||||
// maxResults rather than running out of work.
|
||||
func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig, maxResults int) ([]*types.TaskDetectionResult, bool, error) {
|
||||
if !config.IsEnabled() {
|
||||
return nil, false, nil
|
||||
}
|
||||
if clusterInfo == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
balanceConfig := config.(*Config)
|
||||
|
||||
if maxResults <= 0 {
|
||||
maxResults = math.MaxInt32
|
||||
}
|
||||
|
||||
// Group volumes by disk type to ensure we compare apples to apples.
|
||||
// Remote-tiered volumes are skipped: a "move" copies .idx/.vif then
|
||||
// Volume.Destroy on the source deletes the remote object the destination
|
||||
// now points at. Mirrors shell/command_volume_balance.go.
|
||||
volumesByDiskType := make(map[string][]*types.VolumeHealthMetrics)
|
||||
for _, metric := range metrics {
|
||||
if metric.HasRemoteCopy {
|
||||
continue
|
||||
}
|
||||
volumesByDiskType[metric.DiskType] = append(volumesByDiskType[metric.DiskType], metric)
|
||||
}
|
||||
|
||||
// Sort disk types for deterministic iteration order when maxResults
|
||||
// spans multiple disk types.
|
||||
diskTypes := make([]string, 0, len(volumesByDiskType))
|
||||
for dt := range volumesByDiskType {
|
||||
diskTypes = append(diskTypes, dt)
|
||||
}
|
||||
sort.Strings(diskTypes)
|
||||
|
||||
var allParams []*types.TaskDetectionResult
|
||||
truncated := false
|
||||
|
||||
for _, diskType := range diskTypes {
|
||||
remaining := maxResults - len(allParams)
|
||||
if remaining <= 0 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
tasks, diskTruncated := detectForDiskType(diskType, volumesByDiskType[diskType], balanceConfig, clusterInfo, remaining)
|
||||
allParams = append(allParams, tasks...)
|
||||
if diskTruncated {
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
return allParams, truncated, nil
|
||||
}
|
||||
|
||||
// detectForDiskType performs balance detection for a specific disk type,
|
||||
// returning up to maxResults balance tasks and whether it was truncated by the limit.
|
||||
func detectForDiskType(diskType string, diskMetrics []*types.VolumeHealthMetrics, balanceConfig *Config, clusterInfo *types.ClusterInfo, maxResults int) ([]*types.TaskDetectionResult, bool) {
|
||||
// Skip if cluster segment is too small
|
||||
minVolumeCount := 2 // More reasonable for small clusters
|
||||
if len(diskMetrics) < minVolumeCount {
|
||||
// Only log at verbose level to avoid spamming for small/empty disk types
|
||||
glog.V(1).Infof("BALANCE [%s]: No tasks created - cluster too small (%d volumes, need ≥%d)", diskType, len(diskMetrics), minVolumeCount)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Analyze volume distribution across servers.
|
||||
// Seed from ActiveTopology so servers with matching disk type but zero
|
||||
// volumes are included in the count and imbalance calculation.
|
||||
// Also collect MaxVolumeCount per server to compute utilization ratios.
|
||||
serverVolumeCounts := make(map[string]int)
|
||||
serverMaxVolumes := make(map[string]int64)
|
||||
serverDiskTotalBytes := make(map[string]uint64)
|
||||
serverDiskFreeBytes := make(map[string]uint64)
|
||||
if clusterInfo.ActiveTopology != nil {
|
||||
topologyInfo := clusterInfo.ActiveTopology.GetTopologyInfo()
|
||||
if topologyInfo != nil {
|
||||
dcMatchers := wildcard.CompileWildcardMatchers(balanceConfig.DataCenterFilter)
|
||||
rackMatchers := wildcard.CompileWildcardMatchers(balanceConfig.RackFilter)
|
||||
nodeMatchers := wildcard.CompileWildcardMatchers(balanceConfig.NodeFilter)
|
||||
for _, dc := range topologyInfo.DataCenterInfos {
|
||||
if !wildcard.MatchesAnyWildcard(dcMatchers, dc.Id) {
|
||||
continue
|
||||
}
|
||||
for _, rack := range dc.RackInfos {
|
||||
if !wildcard.MatchesAnyWildcard(rackMatchers, rack.Id) {
|
||||
continue
|
||||
}
|
||||
for _, node := range rack.DataNodeInfos {
|
||||
if !wildcard.MatchesAnyWildcard(nodeMatchers, node.Id) {
|
||||
continue
|
||||
}
|
||||
for diskTypeName, diskInfo := range node.DiskInfos {
|
||||
if diskTypeName == diskType {
|
||||
serverVolumeCounts[node.Id] = 0
|
||||
serverMaxVolumes[node.Id] += diskInfo.MaxVolumeCount
|
||||
serverDiskTotalBytes[node.Id] += diskInfo.DiskTotalBytes
|
||||
serverDiskFreeBytes[node.Id] += diskInfo.DiskFreeBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasLocationFilter := balanceConfig.DataCenterFilter != "" || balanceConfig.RackFilter != "" || balanceConfig.NodeFilter != ""
|
||||
for _, metric := range diskMetrics {
|
||||
if hasLocationFilter {
|
||||
// Only count metrics for servers that passed filtering.
|
||||
// Without this guard, out-of-scope servers are re-introduced.
|
||||
if _, allowed := serverVolumeCounts[metric.Server]; !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
serverVolumeCounts[metric.Server]++
|
||||
}
|
||||
|
||||
if len(serverVolumeCounts) < balanceConfig.MinServerCount {
|
||||
glog.V(1).Infof("BALANCE [%s]: No tasks created - too few servers (%d servers, need ≥%d)", diskType, len(serverVolumeCounts), balanceConfig.MinServerCount)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Seed adjustments from existing pending/assigned balance tasks so that
|
||||
// effectiveCounts reflects in-flight moves and prevents over-scheduling.
|
||||
var adjustments map[string]int
|
||||
if clusterInfo.ActiveTopology != nil {
|
||||
adjustments = clusterInfo.ActiveTopology.GetTaskServerAdjustments(topology.TaskTypeBalance)
|
||||
}
|
||||
if adjustments == nil {
|
||||
adjustments = make(map[string]int)
|
||||
}
|
||||
// Servers where we can no longer find eligible volumes or plan destinations
|
||||
exhaustedServers := make(map[string]bool)
|
||||
|
||||
// Sort servers for deterministic iteration and tie-breaking
|
||||
sortedServers := make([]string, 0, len(serverVolumeCounts))
|
||||
for server := range serverVolumeCounts {
|
||||
sortedServers = append(sortedServers, server)
|
||||
}
|
||||
sort.Strings(sortedServers)
|
||||
|
||||
// Pre-index volumes by server with cursors to avoid O(maxResults * volumes) scanning.
|
||||
// Sort each server's volumes by VolumeID for deterministic selection.
|
||||
volumesByServer := make(map[string][]*types.VolumeHealthMetrics, len(serverVolumeCounts))
|
||||
for _, metric := range diskMetrics {
|
||||
volumesByServer[metric.Server] = append(volumesByServer[metric.Server], metric)
|
||||
}
|
||||
for _, vols := range volumesByServer {
|
||||
sort.Slice(vols, func(i, j int) bool {
|
||||
return vols[i].VolumeID < vols[j].VolumeID
|
||||
})
|
||||
}
|
||||
serverCursors := make(map[string]int, len(serverVolumeCounts))
|
||||
|
||||
var results []*types.TaskDetectionResult
|
||||
balanced := false
|
||||
|
||||
// Decide upfront whether all servers have MaxVolumeCount info.
|
||||
// If any server is missing it, fall back to raw counts for ALL servers
|
||||
// to avoid mixing utilization ratios (0.0–1.0) with raw counts.
|
||||
allServersHaveMaxInfo := true
|
||||
for _, server := range sortedServers {
|
||||
if maxVol, ok := serverMaxVolumes[server]; !ok || maxVol <= 0 {
|
||||
allServersHaveMaxInfo = false
|
||||
glog.V(1).Infof("BALANCE [%s]: Server %s is missing MaxVolumeCount info, falling back to raw volume counts for balancing", diskType, server)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var serverUtilization func(server string, effectiveCount int) float64
|
||||
if allServersHaveMaxInfo {
|
||||
serverUtilization = func(server string, effectiveCount int) float64 {
|
||||
return float64(effectiveCount) / float64(serverMaxVolumes[server])
|
||||
}
|
||||
} else {
|
||||
serverUtilization = func(_ string, effectiveCount int) float64 {
|
||||
return float64(effectiveCount)
|
||||
}
|
||||
}
|
||||
|
||||
// plannedBytes tracks data already routed to each destination earlier in this
|
||||
// detection cycle, so the disk-fullness gate sees a target fill up as moves are
|
||||
// planned instead of only its heartbeat-time free space (the shell equivalent
|
||||
// is adjustAfterMove decrementing DiskFreeBytes).
|
||||
plannedBytes := make(map[string]uint64)
|
||||
// destinationFull excludes servers a projected move would push over, for the cycle.
|
||||
destinationFull := make(map[string]bool)
|
||||
|
||||
// destinationDiskTooFull reports whether landing incomingBytes on server would put
|
||||
// its disk at/over the mark, net of bytes already planned this cycle; unreported
|
||||
// disks are never gated.
|
||||
destinationDiskTooFull := func(server string, incomingBytes uint64) bool {
|
||||
free := serverDiskFreeBytes[server]
|
||||
if planned := plannedBytes[server]; planned < free {
|
||||
free -= planned
|
||||
} else {
|
||||
free = 0
|
||||
}
|
||||
return balancer.DiskTooFullAfter(serverDiskTotalBytes[server], free, incomingBytes, balancer.DefaultMaxDiskUsagePercent)
|
||||
}
|
||||
|
||||
for len(results) < maxResults {
|
||||
// Compute effective volume counts with adjustments from planned moves
|
||||
effectiveCounts := make(map[string]int, len(serverVolumeCounts))
|
||||
for server, count := range serverVolumeCounts {
|
||||
effective := count + adjustments[server]
|
||||
if effective < 0 {
|
||||
effective = 0
|
||||
}
|
||||
effectiveCounts[server] = effective
|
||||
}
|
||||
|
||||
// Find the most and least utilized servers using utilization ratio
|
||||
// (volumes / maxVolumes) so that servers with higher capacity are
|
||||
// expected to hold proportionally more volumes.
|
||||
maxUtilization := -1.0
|
||||
minUtilization := math.Inf(1)
|
||||
maxServer := ""
|
||||
minServer := ""
|
||||
|
||||
for _, server := range sortedServers {
|
||||
count := effectiveCounts[server]
|
||||
util := serverUtilization(server, count)
|
||||
// Min is the emptiest server that can actually receive a volume, so a
|
||||
// physically full server (whose over-set maxVolumeCount makes its slot
|
||||
// utilization look low) is never chosen as the destination.
|
||||
if !destinationFull[server] && !destinationDiskTooFull(server, 0) && util < minUtilization {
|
||||
minUtilization = util
|
||||
minServer = server
|
||||
}
|
||||
// Max is only among non-exhausted servers since we can only move from them
|
||||
if exhaustedServers[server] {
|
||||
continue
|
||||
}
|
||||
if util > maxUtilization {
|
||||
maxUtilization = util
|
||||
maxServer = server
|
||||
}
|
||||
}
|
||||
|
||||
if maxServer == "" {
|
||||
// All servers exhausted
|
||||
glog.V(1).Infof("BALANCE [%s]: All overloaded servers exhausted after %d task(s)", diskType, len(results))
|
||||
break
|
||||
}
|
||||
|
||||
if minServer == "" {
|
||||
// Every candidate destination is at/above the physical disk high-water
|
||||
// mark, so no move can safely improve balance.
|
||||
glog.V(1).Infof("BALANCE [%s]: No eligible destination - all candidates at/above %d%% disk usage after %d task(s)", diskType, balancer.DefaultMaxDiskUsagePercent, len(results))
|
||||
break
|
||||
}
|
||||
|
||||
// Check if utilization imbalance exceeds threshold.
|
||||
// imbalanceRatio is the difference between the most and least utilized
|
||||
// servers, expressed as a fraction of mean utilization.
|
||||
avgUtilization := (maxUtilization + minUtilization) / 2.0
|
||||
var imbalanceRatio float64
|
||||
if avgUtilization > 0 {
|
||||
imbalanceRatio = (maxUtilization - minUtilization) / avgUtilization
|
||||
}
|
||||
if imbalanceRatio <= balanceConfig.ImbalanceThreshold {
|
||||
if len(results) == 0 {
|
||||
glog.Infof("BALANCE [%s]: No tasks created - cluster well balanced. Imbalance=%.1f%% (threshold=%.1f%%). MaxUtil=%.1f%% on %s, MinUtil=%.1f%% on %s",
|
||||
diskType, imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100, maxUtilization*100, maxServer, minUtilization*100, minServer)
|
||||
} else {
|
||||
glog.Infof("BALANCE [%s]: Created %d task(s), cluster now balanced. Imbalance=%.1f%% (threshold=%.1f%%)",
|
||||
diskType, len(results), imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100)
|
||||
}
|
||||
balanced = true
|
||||
break
|
||||
}
|
||||
|
||||
// When the global max and min effective counts differ by at most 1,
|
||||
// no single move can improve balance — it would just swap which server
|
||||
// is min vs max. Stop here to avoid infinite oscillation when the
|
||||
// threshold is unachievable (e.g., 11 vols across 4 servers: best is
|
||||
// 3/3/3/2, imbalance=36%). We scan ALL servers' effective counts so the
|
||||
// check works regardless of whether utilization or raw counts are used.
|
||||
globalMaxCount, globalMinCount := 0, math.MaxInt
|
||||
for _, c := range effectiveCounts {
|
||||
if c > globalMaxCount {
|
||||
globalMaxCount = c
|
||||
}
|
||||
if c < globalMinCount {
|
||||
globalMinCount = c
|
||||
}
|
||||
}
|
||||
if globalMaxCount-globalMinCount <= 1 {
|
||||
if len(results) == 0 {
|
||||
glog.Infof("BALANCE [%s]: No tasks created - cluster as balanced as possible. Imbalance=%.1f%% (threshold=%.1f%%), but max-min diff is %d",
|
||||
diskType, imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100, globalMaxCount-globalMinCount)
|
||||
} else {
|
||||
glog.Infof("BALANCE [%s]: Created %d task(s), cluster as balanced as possible. Imbalance=%.1f%% (threshold=%.1f%%), max-min diff=%d",
|
||||
diskType, len(results), imbalanceRatio*100, balanceConfig.ImbalanceThreshold*100, globalMaxCount-globalMinCount)
|
||||
}
|
||||
balanced = true
|
||||
break
|
||||
}
|
||||
|
||||
// Per-move convergence guard: mirrors weed/shell/command_volume_balance.go
|
||||
// to prevent oscillation and destination overshoot when MaxVolumeCount
|
||||
// values are heterogeneous. Without this guard, the greedy max→min
|
||||
// algorithm can schedule moves that flip which server is most-utilized
|
||||
// (source becomes min, destination becomes max), producing A→B, B→A
|
||||
// oscillation within a single detection cycle.
|
||||
//
|
||||
// Check: after the move, the destination's utilization must not strictly
|
||||
// exceed the source's utilization. If it would, no single move can
|
||||
// improve balance — stop here. This also handles heterogeneous capacity
|
||||
// correctly by comparing post-move utilization ratios rather than raw
|
||||
// counts. The integer discretization is handled automatically: when
|
||||
// counts cannot match the ideal exactly, the check still admits moves
|
||||
// that reduce the max/min gap without flipping it.
|
||||
maxCap := serverMaxVolumes[maxServer]
|
||||
minCap := serverMaxVolumes[minServer]
|
||||
if allServersHaveMaxInfo && maxCap > 0 && minCap > 0 {
|
||||
newSrcUtil := float64(effectiveCounts[maxServer]-1) / float64(maxCap)
|
||||
newDstUtil := float64(effectiveCounts[minServer]+1) / float64(minCap)
|
||||
if newDstUtil > newSrcUtil {
|
||||
if len(results) == 0 {
|
||||
glog.Infof("BALANCE [%s]: No tasks created - no beneficial move available. After move, dest %s util would be %.1f%% vs source %s util %.1f%%",
|
||||
diskType, minServer, newDstUtil*100, maxServer, newSrcUtil*100)
|
||||
} else {
|
||||
glog.Infof("BALANCE [%s]: Created %d task(s), no more beneficial moves available. After move, dest %s util would be %.1f%% vs source %s util %.1f%%",
|
||||
diskType, len(results), minServer, newDstUtil*100, maxServer, newSrcUtil*100)
|
||||
}
|
||||
balanced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Select a volume from the overloaded server using per-server cursor
|
||||
var selectedVolume *types.VolumeHealthMetrics
|
||||
serverVols := volumesByServer[maxServer]
|
||||
cursor := serverCursors[maxServer]
|
||||
for cursor < len(serverVols) {
|
||||
metric := serverVols[cursor]
|
||||
cursor++
|
||||
// Skip volumes that already have a task in ActiveTopology
|
||||
if clusterInfo.ActiveTopology != nil && clusterInfo.ActiveTopology.HasAnyTask(metric.VolumeID) {
|
||||
continue
|
||||
}
|
||||
selectedVolume = metric
|
||||
break
|
||||
}
|
||||
serverCursors[maxServer] = cursor
|
||||
|
||||
if selectedVolume == nil {
|
||||
glog.V(1).Infof("BALANCE [%s]: No more eligible volumes on overloaded server %s, trying other servers", diskType, maxServer)
|
||||
exhaustedServers[maxServer] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip a destination this specific volume would push over the mark, and
|
||||
// re-pick, rather than overshoot.
|
||||
if destinationDiskTooFull(minServer, uint64(selectedVolume.Size)) {
|
||||
glog.V(1).Infof("BALANCE [%s]: skip destination %s: volume %d (%d bytes) would cross %d%% disk usage",
|
||||
diskType, minServer, selectedVolume.VolumeID, selectedVolume.Size, balancer.DefaultMaxDiskUsagePercent)
|
||||
destinationFull[minServer] = true
|
||||
serverCursors[maxServer]-- // retry this volume against another destination
|
||||
continue
|
||||
}
|
||||
|
||||
// Create task targeting minServer — the greedy algorithm's natural choice.
|
||||
// Using minServer instead of letting planBalanceDestination independently
|
||||
// pick a destination ensures that the detection loop's effective counts
|
||||
// and the destination selection stay in sync. Without this, the topology's
|
||||
// LoadCount-based scoring can diverge from the adjustment-based effective
|
||||
// counts, causing moves to pile onto one server or oscillate (A→B, B→A).
|
||||
//
|
||||
// Constrain the destination (including the score-based fallback inside
|
||||
// createBalanceTask) to servers whose physical disk is not near full;
|
||||
// sources are unaffected, so a full server can still be drained.
|
||||
eligibleTargets := make(map[string]int, len(serverVolumeCounts))
|
||||
for s, c := range serverVolumeCounts {
|
||||
if !destinationFull[s] && !destinationDiskTooFull(s, 0) {
|
||||
eligibleTargets[s] = c
|
||||
}
|
||||
}
|
||||
task, destServerID := createBalanceTask(diskType, selectedVolume, clusterInfo, minServer, eligibleTargets, balanceConfig.IoBytePerSecond)
|
||||
if task == nil {
|
||||
glog.V(1).Infof("BALANCE [%s]: Cannot plan task for volume %d on server %s, trying next volume", diskType, selectedVolume.VolumeID, maxServer)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, task)
|
||||
|
||||
// Adjust effective counts for the next iteration.
|
||||
adjustments[maxServer]--
|
||||
if destServerID != "" {
|
||||
adjustments[destServerID]++
|
||||
// Charge the moved volume's bytes to the destination so the disk-fullness
|
||||
// gate sees it fill up over the course of this cycle.
|
||||
plannedBytes[destServerID] += uint64(selectedVolume.Size)
|
||||
// If the destination server wasn't in serverVolumeCounts (e.g., a
|
||||
// server with 0 volumes not seeded from topology), add it so
|
||||
// subsequent iterations include it in effective/average/min/max.
|
||||
if _, exists := serverVolumeCounts[destServerID]; !exists {
|
||||
serverVolumeCounts[destServerID] = 0
|
||||
sortedServers = append(sortedServers, destServerID)
|
||||
sort.Strings(sortedServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncated only if we hit maxResults and detection didn't naturally finish
|
||||
truncated := len(results) >= maxResults && !balanced
|
||||
return results, truncated
|
||||
}
|
||||
|
||||
// createBalanceTask creates a single balance task for the selected volume.
|
||||
// targetServer is the server ID chosen by the detection loop's greedy algorithm.
|
||||
// Returns (nil, "") if destination planning fails.
|
||||
// On success, returns the task result and the canonical destination server ID.
|
||||
// allowedServers is the set of servers that passed DC/rack/node filtering in
|
||||
// the detection loop. When non-empty, the fallback destination planner is
|
||||
// checked against this set so that filter scope cannot leak.
|
||||
func createBalanceTask(diskType string, selectedVolume *types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, targetServer string, allowedServers map[string]int, ioBytePerSecond int64) (*types.TaskDetectionResult, string) {
|
||||
taskID := fmt.Sprintf("balance_vol_%d_%d", selectedVolume.VolumeID, time.Now().UnixNano())
|
||||
|
||||
task := &types.TaskDetectionResult{
|
||||
TaskID: taskID,
|
||||
TaskType: types.TaskTypeBalance,
|
||||
VolumeID: selectedVolume.VolumeID,
|
||||
Server: selectedVolume.Server,
|
||||
Collection: selectedVolume.Collection,
|
||||
Priority: types.TaskPriorityNormal,
|
||||
Reason: fmt.Sprintf("Cluster imbalance detected for %s disk type",
|
||||
diskType),
|
||||
ScheduleAt: time.Now(),
|
||||
}
|
||||
|
||||
// Plan destination if ActiveTopology is available
|
||||
if clusterInfo.ActiveTopology == nil {
|
||||
glog.Warningf("No ActiveTopology available for destination planning in balance detection")
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Parse replica placement once for use in both scoring and validation.
|
||||
var volumeRP *super_block.ReplicaPlacement
|
||||
if selectedVolume.ExpectedReplicas > 0 && selectedVolume.ExpectedReplicas <= 255 {
|
||||
if parsed, rpErr := super_block.NewReplicaPlacementFromByte(byte(selectedVolume.ExpectedReplicas)); rpErr == nil && parsed.HasReplication() {
|
||||
volumeRP = parsed
|
||||
}
|
||||
}
|
||||
|
||||
var replicas []types.ReplicaLocation
|
||||
if clusterInfo.VolumeReplicaMap != nil {
|
||||
replicas = clusterInfo.VolumeReplicaMap[selectedVolume.VolumeID]
|
||||
if volumeRP != nil && len(replicas) == 0 {
|
||||
glog.V(1).Infof("BALANCE [%s]: No replica locations found for volume %d, skipping placement validation",
|
||||
diskType, selectedVolume.VolumeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the target server chosen by the detection loop's effective counts.
|
||||
// This keeps destination selection in sync with the greedy algorithm rather
|
||||
// than relying on topology LoadCount which can diverge across iterations.
|
||||
destinationPlan, err := resolveBalanceDestination(clusterInfo.ActiveTopology, selectedVolume, targetServer)
|
||||
if err != nil {
|
||||
// Fall back to score-based planning if the preferred target can't be resolved
|
||||
glog.V(1).Infof("BALANCE [%s]: Cannot resolve target %s for volume %d, falling back to score-based planning: %v",
|
||||
diskType, targetServer, selectedVolume.VolumeID, err)
|
||||
destinationPlan, err = planBalanceDestination(clusterInfo.ActiveTopology, selectedVolume, volumeRP, replicas, allowedServers)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to plan balance destination for volume %d: %v", selectedVolume.VolumeID, err)
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the resolved destination. If it falls outside the filtered scope
|
||||
// or violates replica placement, fall back to the score-based planner and
|
||||
// pick the best candidate that is actually valid.
|
||||
if !isValidBalanceDestination(destinationPlan, allowedServers, volumeRP, replicas, selectedVolume.Server) {
|
||||
switch {
|
||||
case destinationPlan == nil:
|
||||
glog.V(1).Infof("BALANCE [%s]: Planned destination for volume %d is nil, falling back",
|
||||
diskType, selectedVolume.VolumeID)
|
||||
case !isAllowedBalanceTarget(destinationPlan.TargetNode, allowedServers):
|
||||
glog.V(1).Infof("BALANCE [%s]: Planned destination %s for volume %d is outside filtered scope, falling back",
|
||||
diskType, destinationPlan.TargetNode, selectedVolume.VolumeID)
|
||||
case volumeRP != nil && len(replicas) > 0:
|
||||
glog.V(1).Infof("BALANCE [%s]: Destination %s violates replica placement for volume %d (rp=%03d), falling back",
|
||||
diskType, destinationPlan.TargetNode, selectedVolume.VolumeID, selectedVolume.ExpectedReplicas)
|
||||
}
|
||||
|
||||
destinationPlan, err = planBalanceDestination(clusterInfo.ActiveTopology, selectedVolume, volumeRP, replicas, allowedServers)
|
||||
if err != nil {
|
||||
glog.Warningf("BALANCE [%s]: Failed to plan fallback destination for volume %d: %v", diskType, selectedVolume.VolumeID, err)
|
||||
return nil, ""
|
||||
}
|
||||
if !isValidBalanceDestination(destinationPlan, allowedServers, volumeRP, replicas, selectedVolume.Server) {
|
||||
if destinationPlan == nil {
|
||||
glog.V(1).Infof("BALANCE [%s]: Fallback destination for volume %d is nil",
|
||||
diskType, selectedVolume.VolumeID)
|
||||
} else {
|
||||
glog.V(1).Infof("BALANCE [%s]: Fallback destination %s is not valid for volume %d",
|
||||
diskType, destinationPlan.TargetNode, selectedVolume.VolumeID)
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Find the actual disk containing the volume on the source server
|
||||
sourceDisk, found := base.FindVolumeDisk(clusterInfo.ActiveTopology, selectedVolume.VolumeID, selectedVolume.Collection, selectedVolume.Server)
|
||||
if !found {
|
||||
glog.Warningf("BALANCE [%s]: Could not find volume %d (collection: %s) on source server %s - unable to create balance task",
|
||||
diskType, selectedVolume.VolumeID, selectedVolume.Collection, selectedVolume.Server)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Update reason with full details now that we have destination info
|
||||
task.Reason = fmt.Sprintf("Cluster imbalance detected for %s: move volume %d from %s to %s",
|
||||
diskType, selectedVolume.VolumeID, selectedVolume.Server, destinationPlan.TargetNode)
|
||||
|
||||
// Create typed parameters with unified source and target information
|
||||
task.TypedParams = &worker_pb.TaskParams{
|
||||
TaskId: taskID,
|
||||
VolumeId: selectedVolume.VolumeID,
|
||||
Collection: selectedVolume.Collection,
|
||||
VolumeSize: selectedVolume.Size,
|
||||
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: selectedVolume.ServerAddress,
|
||||
DiskId: sourceDisk,
|
||||
VolumeId: selectedVolume.VolumeID,
|
||||
EstimatedSize: selectedVolume.Size,
|
||||
DataCenter: selectedVolume.DataCenter,
|
||||
Rack: selectedVolume.Rack,
|
||||
},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{
|
||||
Node: destinationPlan.TargetAddress,
|
||||
DiskId: destinationPlan.TargetDisk,
|
||||
VolumeId: selectedVolume.VolumeID,
|
||||
EstimatedSize: destinationPlan.ExpectedSize,
|
||||
DataCenter: destinationPlan.TargetDC,
|
||||
Rack: destinationPlan.TargetRack,
|
||||
},
|
||||
},
|
||||
|
||||
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
||||
BalanceParams: &worker_pb.BalanceTaskParams{
|
||||
ForceMove: false,
|
||||
TimeoutSeconds: 600, // 10 minutes default
|
||||
IoBytePerSecond: ioBytePerSecond,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Planned balance destination for volume %d: %s -> %s",
|
||||
selectedVolume.VolumeID, selectedVolume.Server, destinationPlan.TargetNode)
|
||||
|
||||
// Add pending balance task to ActiveTopology for capacity management
|
||||
targetDisk := destinationPlan.TargetDisk
|
||||
|
||||
err = clusterInfo.ActiveTopology.AddPendingTask(topology.TaskSpec{
|
||||
TaskID: taskID,
|
||||
TaskType: topology.TaskTypeBalance,
|
||||
VolumeID: selectedVolume.VolumeID,
|
||||
VolumeSize: int64(selectedVolume.Size),
|
||||
Sources: []topology.TaskSourceSpec{
|
||||
{ServerID: selectedVolume.Server, DiskID: sourceDisk},
|
||||
},
|
||||
Destinations: []topology.TaskDestinationSpec{
|
||||
{ServerID: destinationPlan.TargetNode, DiskID: targetDisk},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
glog.Warningf("BALANCE [%s]: Failed to add pending task for volume %d: %v", diskType, selectedVolume.VolumeID, err)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Added pending balance task %s to ActiveTopology for volume %d: %s:%d -> %s:%d",
|
||||
taskID, selectedVolume.VolumeID, selectedVolume.Server, sourceDisk, destinationPlan.TargetNode, targetDisk)
|
||||
|
||||
return task, destinationPlan.TargetNode
|
||||
}
|
||||
|
||||
// resolveBalanceDestination resolves the destination for a balance operation
|
||||
// when the target server is already known (chosen by the detection loop's
|
||||
// effective volume counts). It finds a disk on the target server that can
|
||||
// accept another volume after accounting for ALL pending and assigned tasks
|
||||
// on that disk (via ActiveTopology.GetEffectiveAvailableCapacity) — not the
|
||||
// static VolumeCount from the topology snapshot. This keeps destination
|
||||
// planning consistent with the loop's effective-count bookkeeping and
|
||||
// prevents over-scheduling when multiple moves are planned within the same
|
||||
// detection cycle.
|
||||
func resolveBalanceDestination(activeTopology *topology.ActiveTopology, selectedVolume *types.VolumeHealthMetrics, targetServer string) (*topology.DestinationPlan, error) {
|
||||
nodeDisks := activeTopology.GetNodeDisks(targetServer)
|
||||
if len(nodeDisks) == 0 {
|
||||
return nil, fmt.Errorf("target server %s not found in topology", targetServer)
|
||||
}
|
||||
|
||||
var eligibleDisk *topology.DiskInfo
|
||||
for _, disk := range nodeDisks {
|
||||
if disk == nil || disk.DiskInfo == nil {
|
||||
continue
|
||||
}
|
||||
if disk.DiskType != selectedVolume.DiskType {
|
||||
continue
|
||||
}
|
||||
// Use effective capacity so that prior moves planned in this same
|
||||
// detection cycle (already registered as pending tasks) reduce the
|
||||
// available slots. A disk with VolumeCount << MaxVolumeCount in the
|
||||
// topology snapshot can still be effectively full if many in-flight
|
||||
// tasks already target it.
|
||||
if disk.DiskInfo.MaxVolumeCount > 0 {
|
||||
available := activeTopology.GetEffectiveAvailableCapacity(disk.NodeID, disk.DiskID)
|
||||
if available <= 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
eligibleDisk = disk
|
||||
break
|
||||
}
|
||||
|
||||
if eligibleDisk == nil {
|
||||
return nil, fmt.Errorf("target server %s has no available disk of type %s", targetServer, selectedVolume.DiskType)
|
||||
}
|
||||
|
||||
targetAddress, err := util.ResolveServerAddress(eligibleDisk.NodeID, activeTopology)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve address for target server %s: %v", eligibleDisk.NodeID, err)
|
||||
}
|
||||
return &topology.DestinationPlan{
|
||||
TargetNode: eligibleDisk.NodeID,
|
||||
TargetAddress: targetAddress,
|
||||
TargetDisk: eligibleDisk.DiskID,
|
||||
TargetRack: eligibleDisk.Rack,
|
||||
TargetDC: eligibleDisk.DataCenter,
|
||||
ExpectedSize: selectedVolume.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// planBalanceDestination plans the destination for a balance operation using
|
||||
// score-based selection. Used as a fallback when the preferred target cannot
|
||||
// be resolved, and for single-move scenarios outside the detection loop.
|
||||
// rp may be nil when the volume has no replication constraint. When replica
|
||||
// locations are known, candidates that would violate placement are filtered
|
||||
// out before scoring.
|
||||
func planBalanceDestination(activeTopology *topology.ActiveTopology, selectedVolume *types.VolumeHealthMetrics, rp *super_block.ReplicaPlacement, replicas []types.ReplicaLocation, allowedServers map[string]int) (*topology.DestinationPlan, error) {
|
||||
// Get source node information from topology
|
||||
var sourceRack, sourceDC string
|
||||
|
||||
// Extract rack and DC from topology info
|
||||
topologyInfo := activeTopology.GetTopologyInfo()
|
||||
if topologyInfo != nil {
|
||||
for _, dc := range topologyInfo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
for _, dataNodeInfo := range rack.DataNodeInfos {
|
||||
if dataNodeInfo.Id == selectedVolume.Server {
|
||||
sourceDC = dc.Id
|
||||
sourceRack = rack.Id
|
||||
break
|
||||
}
|
||||
}
|
||||
if sourceRack != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sourceDC != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get available disks, excluding the source node
|
||||
availableDisks := activeTopology.GetAvailableDisks(topology.TaskTypeBalance, selectedVolume.Server)
|
||||
if len(availableDisks) == 0 {
|
||||
return nil, fmt.Errorf("no available disks for balance operation")
|
||||
}
|
||||
|
||||
// Sort available disks by NodeID then DiskID for deterministic tie-breaking
|
||||
sort.Slice(availableDisks, func(i, j int) bool {
|
||||
if availableDisks[i].NodeID != availableDisks[j].NodeID {
|
||||
return availableDisks[i].NodeID < availableDisks[j].NodeID
|
||||
}
|
||||
return availableDisks[i].DiskID < availableDisks[j].DiskID
|
||||
})
|
||||
|
||||
// Find the best destination disk based on balance criteria
|
||||
var bestDisk *topology.DiskInfo
|
||||
bestScore := math.Inf(-1)
|
||||
|
||||
for _, disk := range availableDisks {
|
||||
// Ensure disk type matches
|
||||
if disk.DiskType != selectedVolume.DiskType {
|
||||
continue
|
||||
}
|
||||
if !isAllowedBalanceTarget(disk.NodeID, allowedServers) {
|
||||
continue
|
||||
}
|
||||
if rp != nil && len(replicas) > 0 {
|
||||
target := types.ReplicaLocation{
|
||||
DataCenter: disk.DataCenter,
|
||||
Rack: disk.Rack,
|
||||
NodeID: disk.NodeID,
|
||||
Host: hostFromAddress(disk.Address, disk.NodeID),
|
||||
}
|
||||
if !IsGoodMove(rp, replicas, selectedVolume.Server, target) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
score := calculateBalanceScore(disk, sourceRack, sourceDC, selectedVolume.Size, rp)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestDisk = disk
|
||||
}
|
||||
}
|
||||
|
||||
if bestDisk == nil {
|
||||
return nil, fmt.Errorf("no suitable destination found for balance operation")
|
||||
}
|
||||
|
||||
// Get the target server address
|
||||
targetAddress, err := util.ResolveServerAddress(bestDisk.NodeID, activeTopology)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve address for target server %s: %v", bestDisk.NodeID, err)
|
||||
}
|
||||
|
||||
return &topology.DestinationPlan{
|
||||
TargetNode: bestDisk.NodeID,
|
||||
TargetAddress: targetAddress,
|
||||
TargetDisk: bestDisk.DiskID,
|
||||
TargetRack: bestDisk.Rack,
|
||||
TargetDC: bestDisk.DataCenter,
|
||||
ExpectedSize: selectedVolume.Size,
|
||||
PlacementScore: bestScore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// calculateBalanceScore calculates placement score for balance operations.
|
||||
// LoadCount reflects pending+assigned tasks on the disk, so we factor it into
|
||||
// the utilization estimate to avoid stacking multiple moves onto the same target.
|
||||
// rp may be nil when the volume has no replication constraint; in that case the
|
||||
// scorer defaults to preferring cross-rack/DC distribution.
|
||||
func calculateBalanceScore(disk *topology.DiskInfo, sourceRack, sourceDC string, volumeSize uint64, rp *super_block.ReplicaPlacement) float64 {
|
||||
if disk.DiskInfo == nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
score := 0.0
|
||||
|
||||
// Prefer disks with lower effective volume count (current + pending moves).
|
||||
// LoadCount is included so that disks already targeted by planned moves
|
||||
// appear more utilized, naturally spreading work across targets.
|
||||
if disk.DiskInfo.MaxVolumeCount > 0 {
|
||||
effectiveVolumeCount := float64(disk.DiskInfo.VolumeCount) + float64(disk.LoadCount)
|
||||
utilization := effectiveVolumeCount / float64(disk.DiskInfo.MaxVolumeCount)
|
||||
score += (1.0 - utilization) * 50.0 // Up to 50 points for low utilization
|
||||
}
|
||||
|
||||
// Rack scoring: respect the replication policy.
|
||||
// If replicas must stay on the same rack (SameRackCount > 0 with no
|
||||
// cross-rack requirement), prefer same-rack destinations. Otherwise
|
||||
// prefer different racks for better distribution.
|
||||
sameRack := disk.Rack == sourceRack
|
||||
if rp != nil && rp.DiffRackCount == 0 && rp.SameRackCount > 0 {
|
||||
if sameRack {
|
||||
score += 30.0
|
||||
}
|
||||
} else {
|
||||
if !sameRack {
|
||||
score += 30.0
|
||||
}
|
||||
}
|
||||
|
||||
// DC scoring: same idea. If the policy requires all copies in one DC,
|
||||
// prefer same-DC destinations. Otherwise prefer different DCs.
|
||||
sameDC := disk.DataCenter == sourceDC
|
||||
if rp != nil && rp.DiffDataCenterCount == 0 && (rp.SameRackCount > 0 || rp.DiffRackCount > 0) {
|
||||
if sameDC {
|
||||
score += 20.0
|
||||
}
|
||||
} else {
|
||||
if !sameDC {
|
||||
score += 20.0
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func isAllowedBalanceTarget(nodeID string, allowedServers map[string]int) bool {
|
||||
if len(allowedServers) == 0 {
|
||||
return true
|
||||
}
|
||||
_, ok := allowedServers[nodeID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isValidBalanceDestination(plan *topology.DestinationPlan, allowedServers map[string]int, rp *super_block.ReplicaPlacement, replicas []types.ReplicaLocation, sourceNodeID string) bool {
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
if !isAllowedBalanceTarget(plan.TargetNode, allowedServers) {
|
||||
return false
|
||||
}
|
||||
if rp == nil || len(replicas) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
target := types.ReplicaLocation{
|
||||
DataCenter: plan.TargetDC,
|
||||
Rack: plan.TargetRack,
|
||||
NodeID: plan.TargetNode,
|
||||
Host: hostFromAddress(plan.TargetAddress, plan.TargetNode),
|
||||
}
|
||||
return IsGoodMove(rp, replicas, sourceNodeID, target)
|
||||
}
|
||||
|
||||
// parseCSVSet splits a comma-separated string into a set of trimmed, non-empty values.
|
||||
513
weed/worker/tasks/balance/detection_integration_test.go
Normal file
513
weed/worker/tasks/balance/detection_integration_test.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
const (
|
||||
fullnessThreshold = 1.01
|
||||
stateActive = "ACTIVE"
|
||||
stateFull = "FULL"
|
||||
)
|
||||
|
||||
// filterByState filters metrics by volume state for testing.
|
||||
func filterByState(metrics []*types.VolumeHealthMetrics, state string) []*types.VolumeHealthMetrics {
|
||||
if state != stateActive && state != stateFull {
|
||||
return metrics
|
||||
}
|
||||
var out []*types.VolumeHealthMetrics
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if state == stateActive && m.FullnessRatio < fullnessThreshold {
|
||||
out = append(out, m)
|
||||
}
|
||||
if state == stateFull && m.FullnessRatio >= fullnessThreshold {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Integration tests that exercise multiple features together:
|
||||
// DC/rack/node filters, volume state filtering, replica placement validation,
|
||||
// and collection scoping all interacting within a single detection run.
|
||||
|
||||
// makeVolumesWithOptions generates metrics with additional options.
|
||||
type volumeOption func(m *types.VolumeHealthMetrics)
|
||||
|
||||
func withFullness(ratio float64) volumeOption {
|
||||
return func(m *types.VolumeHealthMetrics) { m.FullnessRatio = ratio }
|
||||
}
|
||||
|
||||
func withReplicas(rp int) volumeOption {
|
||||
return func(m *types.VolumeHealthMetrics) { m.ExpectedReplicas = rp }
|
||||
}
|
||||
|
||||
func makeVolumesWith(server, diskType, dc, rack, collection string, volumeIDBase uint32, n int, opts ...volumeOption) []*types.VolumeHealthMetrics {
|
||||
vols := makeVolumes(server, diskType, dc, rack, collection, volumeIDBase, n)
|
||||
for _, v := range vols {
|
||||
for _, opt := range opts {
|
||||
opt(v)
|
||||
}
|
||||
}
|
||||
return vols
|
||||
}
|
||||
|
||||
// buildReplicaMap builds a VolumeReplicaMap from metrics (each metric is one replica location).
|
||||
func buildReplicaMap(metrics []*types.VolumeHealthMetrics) map[uint32][]types.ReplicaLocation {
|
||||
m := make(map[uint32][]types.ReplicaLocation)
|
||||
for _, metric := range metrics {
|
||||
m[metric.VolumeID] = append(m[metric.VolumeID], types.ReplicaLocation{
|
||||
DataCenter: metric.DataCenter,
|
||||
Rack: metric.Rack,
|
||||
NodeID: metric.Server,
|
||||
})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestIntegration_DCFilterWithVolumeState tests that DC filtering and volume
|
||||
// state filtering compose correctly: only ACTIVE volumes in the specified DC
|
||||
// participate in balancing.
|
||||
func TestIntegration_DCFilterWithVolumeState(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc2", rack: "rack1"},
|
||||
}
|
||||
|
||||
var allMetrics []*types.VolumeHealthMetrics
|
||||
// dc1: node-a has 40 active volumes, node-b has 10 active volumes
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 40, withFullness(0.5))...)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 100, 10, withFullness(0.5))...)
|
||||
// dc1: node-a also has 20 FULL volumes that should be excluded
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 200, 20, withFullness(1.5))...)
|
||||
// dc2: node-c has 50 active volumes (should be excluded by DC filter)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-c", "hdd", "dc2", "rack1", "c1", 300, 50, withFullness(0.5))...)
|
||||
|
||||
// Apply volume state filter (ACTIVE only) first
|
||||
activeMetrics := filterByState(allMetrics, stateActive)
|
||||
// Then apply DC filter
|
||||
dcMetrics := make([]*types.VolumeHealthMetrics, 0)
|
||||
for _, m := range activeMetrics {
|
||||
if m.DataCenter == "dc1" {
|
||||
dcMetrics = append(dcMetrics, m)
|
||||
}
|
||||
}
|
||||
|
||||
at := buildTopology(servers, allMetrics)
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
||||
|
||||
conf := defaultConf()
|
||||
conf.DataCenterFilter = "dc1"
|
||||
|
||||
tasks, _, err := Detection(dcMetrics, clusterInfo, conf, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection failed: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected balance tasks for 40/10 active-only imbalance in dc1, got 0")
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
if task.Server == "node-c" {
|
||||
t.Error("node-c (dc2) should not be a source")
|
||||
}
|
||||
if task.TypedParams != nil {
|
||||
for _, tgt := range task.TypedParams.Targets {
|
||||
if strings.Contains(tgt.Node, "node-c") {
|
||||
t.Error("node-c (dc2) should not be a target")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify convergence uses only the 50 active dc1 volumes (40+10),
|
||||
// not the 20 full volumes
|
||||
effective := computeEffectiveCounts(servers[:2], dcMetrics, tasks)
|
||||
total := 0
|
||||
maxC, minC := 0, len(dcMetrics)
|
||||
for _, c := range effective {
|
||||
total += c
|
||||
if c > maxC {
|
||||
maxC = c
|
||||
}
|
||||
if c < minC {
|
||||
minC = c
|
||||
}
|
||||
}
|
||||
if total != 50 {
|
||||
t.Errorf("Expected 50 total active volumes in dc1, got %d", total)
|
||||
}
|
||||
t.Logf("DC+state filter: %d tasks, effective=%v", len(tasks), effective)
|
||||
}
|
||||
|
||||
// TestIntegration_NodeFilterWithCollections tests that node filtering works
|
||||
// correctly when volumes span multiple collections.
|
||||
func TestIntegration_NodeFilterWithCollections(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
||||
}
|
||||
|
||||
var allMetrics []*types.VolumeHealthMetrics
|
||||
// node-a: 30 "photos" + 20 "videos" = 50 total
|
||||
allMetrics = append(allMetrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "photos", 1, 30)...)
|
||||
allMetrics = append(allMetrics, makeVolumes("node-a", "hdd", "dc1", "rack1", "videos", 100, 20)...)
|
||||
// node-b: 5 "photos" + 5 "videos" = 10 total
|
||||
allMetrics = append(allMetrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "photos", 200, 5)...)
|
||||
allMetrics = append(allMetrics, makeVolumes("node-b", "hdd", "dc1", "rack1", "videos", 300, 5)...)
|
||||
// node-c: 40 volumes (should be excluded by node filter)
|
||||
allMetrics = append(allMetrics, makeVolumes("node-c", "hdd", "dc1", "rack1", "photos", 400, 40)...)
|
||||
|
||||
// Apply node filter
|
||||
filteredMetrics := make([]*types.VolumeHealthMetrics, 0)
|
||||
for _, m := range allMetrics {
|
||||
if m.Server == "node-a" || m.Server == "node-b" {
|
||||
filteredMetrics = append(filteredMetrics, m)
|
||||
}
|
||||
}
|
||||
|
||||
at := buildTopology(servers, allMetrics)
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
||||
|
||||
conf := defaultConf()
|
||||
conf.NodeFilter = "node-a,node-b"
|
||||
|
||||
tasks, _, err := Detection(filteredMetrics, clusterInfo, conf, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection failed: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected tasks for 50/10 imbalance within node-a,node-b")
|
||||
}
|
||||
|
||||
// All moves should be between node-a and node-b only
|
||||
for _, task := range tasks {
|
||||
if task.Server != "node-a" && task.Server != "node-b" {
|
||||
t.Errorf("Source %s should be node-a or node-b", task.Server)
|
||||
}
|
||||
if task.TypedParams != nil {
|
||||
for _, tgt := range task.TypedParams.Targets {
|
||||
if !strings.Contains(tgt.Node, "node-a") && !strings.Contains(tgt.Node, "node-b") {
|
||||
t.Errorf("Target %s should be node-a or node-b", tgt.Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDuplicateVolumes(t, tasks)
|
||||
t.Logf("Node filter with mixed collections: %d tasks", len(tasks))
|
||||
}
|
||||
|
||||
// TestIntegration_ReplicaPlacementWithDCFilter tests that replica placement
|
||||
// validation prevents moves that would violate replication policy even when
|
||||
// DC filtering restricts the available servers.
|
||||
func TestIntegration_ReplicaPlacementWithDCFilter(t *testing.T) {
|
||||
// Setup: 2 DCs, volumes with rp=100 (1 replica in different DC)
|
||||
// DC filter restricts to dc1 only.
|
||||
// Replicas: each volume has one copy in dc1 and one in dc2.
|
||||
// The balancer should NOT move volumes within dc1 to a node that would
|
||||
// violate the cross-DC placement requirement.
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc2", rack: "rack1"},
|
||||
}
|
||||
|
||||
// node-a: 30 volumes with rp=100, node-b: 5 volumes with rp=100
|
||||
// Each volume also has a replica on node-c (dc2)
|
||||
var allMetrics []*types.VolumeHealthMetrics
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 30, withReplicas(100))...)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 100, 5, withReplicas(100))...)
|
||||
// dc2 replicas (not part of filtered metrics, but in replica map)
|
||||
dc2Replicas := makeVolumesWith("node-c", "hdd", "dc2", "rack1", "c1", 1, 30, withReplicas(100))
|
||||
|
||||
// Build replica map: volumes 1-30 have replicas on node-a AND node-c
|
||||
replicaMap := buildReplicaMap(allMetrics)
|
||||
for _, r := range dc2Replicas {
|
||||
replicaMap[r.VolumeID] = append(replicaMap[r.VolumeID], types.ReplicaLocation{
|
||||
DataCenter: r.DataCenter,
|
||||
Rack: r.Rack,
|
||||
NodeID: r.Server,
|
||||
})
|
||||
}
|
||||
|
||||
// Filter to dc1 only
|
||||
dc1Metrics := make([]*types.VolumeHealthMetrics, 0)
|
||||
for _, m := range allMetrics {
|
||||
if m.DataCenter == "dc1" {
|
||||
dc1Metrics = append(dc1Metrics, m)
|
||||
}
|
||||
}
|
||||
|
||||
at := buildTopology(servers, allMetrics)
|
||||
clusterInfo := &types.ClusterInfo{
|
||||
ActiveTopology: at,
|
||||
VolumeReplicaMap: replicaMap,
|
||||
}
|
||||
|
||||
conf := defaultConf()
|
||||
conf.DataCenterFilter = "dc1"
|
||||
|
||||
tasks, _, err := Detection(dc1Metrics, clusterInfo, conf, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection failed: %v", err)
|
||||
}
|
||||
|
||||
// With rp=100, moving a volume from node-a to node-b is valid because
|
||||
// the cross-DC replica on node-c is preserved. The balancer should
|
||||
// produce moves that keep the dc2 replica intact.
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected tasks: 30/5 imbalance with valid cross-DC replicas")
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
// All sources should be from dc1
|
||||
if task.Server != "node-a" && task.Server != "node-b" {
|
||||
t.Errorf("Source %s should be in dc1", task.Server)
|
||||
}
|
||||
// All targets should be in dc1
|
||||
if task.TypedParams != nil {
|
||||
for _, tgt := range task.TypedParams.Targets {
|
||||
if strings.Contains(tgt.Node, "node-c") {
|
||||
t.Errorf("Target should not be node-c (dc2) with dc1 filter")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDuplicateVolumes(t, tasks)
|
||||
t.Logf("Replica placement + DC filter: %d tasks", len(tasks))
|
||||
}
|
||||
|
||||
// TestIntegration_RackFilterWithReplicaPlacement tests that rack filtering
|
||||
// and replica placement validation (rp=010) work together. With rp=010,
|
||||
// replicas must be on different racks. When rack filtering restricts to one
|
||||
// rack, the balancer should still produce valid moves within that rack for
|
||||
// volumes whose replicas satisfy the cross-rack requirement via nodes outside
|
||||
// the filter.
|
||||
func TestIntegration_RackFilterWithReplicaPlacement(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack2"},
|
||||
}
|
||||
|
||||
// rack1: node-a has 30 volumes, node-b has 5
|
||||
// Each volume also has a replica on node-c (rack2), satisfying rp=010
|
||||
var allMetrics []*types.VolumeHealthMetrics
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 30, withReplicas(10))...)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 100, 5, withReplicas(10))...)
|
||||
rack2Replicas := makeVolumesWith("node-c", "hdd", "dc1", "rack2", "c1", 1, 30, withReplicas(10))
|
||||
|
||||
replicaMap := buildReplicaMap(allMetrics)
|
||||
for _, r := range rack2Replicas {
|
||||
replicaMap[r.VolumeID] = append(replicaMap[r.VolumeID], types.ReplicaLocation{
|
||||
DataCenter: r.DataCenter,
|
||||
Rack: r.Rack,
|
||||
NodeID: r.Server,
|
||||
})
|
||||
}
|
||||
|
||||
// Filter to rack1 only
|
||||
rack1Metrics := make([]*types.VolumeHealthMetrics, 0)
|
||||
for _, m := range allMetrics {
|
||||
if m.Rack == "rack1" {
|
||||
rack1Metrics = append(rack1Metrics, m)
|
||||
}
|
||||
}
|
||||
|
||||
at := buildTopology(servers, allMetrics)
|
||||
clusterInfo := &types.ClusterInfo{
|
||||
ActiveTopology: at,
|
||||
VolumeReplicaMap: replicaMap,
|
||||
}
|
||||
|
||||
conf := defaultConf()
|
||||
conf.RackFilter = "rack1"
|
||||
|
||||
tasks, _, err := Detection(rack1Metrics, clusterInfo, conf, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection failed: %v", err)
|
||||
}
|
||||
|
||||
// Moving within rack1 (node-a → node-b) is valid because the cross-rack
|
||||
// replica on node-c (rack2) is preserved.
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected tasks for 30/5 imbalance within rack1")
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
if task.Server == "node-c" {
|
||||
t.Error("node-c (rack2) should not be a source with rack1 filter")
|
||||
}
|
||||
if task.TypedParams != nil {
|
||||
for _, tgt := range task.TypedParams.Targets {
|
||||
if strings.Contains(tgt.Node, "node-c") {
|
||||
t.Error("node-c (rack2) should not be a target with rack1 filter")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDuplicateVolumes(t, tasks)
|
||||
t.Logf("Rack filter + replica placement: %d tasks", len(tasks))
|
||||
}
|
||||
|
||||
// TestIntegration_AllFactors exercises all filtering dimensions simultaneously:
|
||||
// DC filter, rack filter, volume state filter, replica placement validation,
|
||||
// and mixed collections.
|
||||
func TestIntegration_AllFactors(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack2"}, // excluded by rack filter
|
||||
{id: "node-d", diskType: "hdd", diskID: 4, dc: "dc2", rack: "rack1"}, // excluded by DC filter
|
||||
}
|
||||
|
||||
var allMetrics []*types.VolumeHealthMetrics
|
||||
|
||||
// node-a: 25 active "photos" + 10 full "photos" (full excluded by state filter)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "photos", 1, 25, withFullness(0.5), withReplicas(100))...)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "photos", 200, 10, withFullness(1.5), withReplicas(100))...)
|
||||
|
||||
// node-b: 5 active "photos"
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "photos", 100, 5, withFullness(0.5), withReplicas(100))...)
|
||||
|
||||
// node-c: 20 volumes in dc1/rack2 (excluded by rack filter)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-c", "hdd", "dc1", "rack2", "photos", 300, 20, withFullness(0.5))...)
|
||||
|
||||
// node-d: 30 volumes in dc2 (excluded by DC filter, but provides cross-DC replicas)
|
||||
dc2Replicas := makeVolumesWith("node-d", "hdd", "dc2", "rack1", "photos", 1, 25, withFullness(0.5), withReplicas(100))
|
||||
|
||||
// Build replica map: volumes 1-25 have cross-DC replicas on node-d
|
||||
replicaMap := buildReplicaMap(allMetrics)
|
||||
for _, r := range dc2Replicas {
|
||||
replicaMap[r.VolumeID] = append(replicaMap[r.VolumeID], types.ReplicaLocation{
|
||||
DataCenter: r.DataCenter,
|
||||
Rack: r.Rack,
|
||||
NodeID: r.Server,
|
||||
})
|
||||
}
|
||||
|
||||
// Apply all filters: ACTIVE state, dc1, rack1
|
||||
filtered := filterByState(allMetrics, stateActive)
|
||||
var finalMetrics []*types.VolumeHealthMetrics
|
||||
for _, m := range filtered {
|
||||
if m.DataCenter == "dc1" && m.Rack == "rack1" {
|
||||
finalMetrics = append(finalMetrics, m)
|
||||
}
|
||||
}
|
||||
|
||||
at := buildTopology(servers, allMetrics)
|
||||
clusterInfo := &types.ClusterInfo{
|
||||
ActiveTopology: at,
|
||||
VolumeReplicaMap: replicaMap,
|
||||
}
|
||||
|
||||
conf := defaultConf()
|
||||
conf.DataCenterFilter = "dc1"
|
||||
conf.RackFilter = "rack1"
|
||||
|
||||
tasks, _, err := Detection(finalMetrics, clusterInfo, conf, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection failed: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected tasks for 25/5 active imbalance in dc1/rack1")
|
||||
}
|
||||
|
||||
// Verify all moves stay within dc1/rack1 scope
|
||||
for i, task := range tasks {
|
||||
if task.Server != "node-a" && task.Server != "node-b" {
|
||||
t.Errorf("Task %d: source %s should be node-a or node-b", i, task.Server)
|
||||
}
|
||||
if task.TypedParams != nil {
|
||||
for _, tgt := range task.TypedParams.Targets {
|
||||
node := tgt.Node
|
||||
if !strings.Contains(node, "node-a") && !strings.Contains(node, "node-b") {
|
||||
t.Errorf("Task %d: target %s should be node-a or node-b", i, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDuplicateVolumes(t, tasks)
|
||||
|
||||
// Verify convergence
|
||||
effective := computeEffectiveCounts(servers[:2], finalMetrics, tasks)
|
||||
total := 0
|
||||
maxC, minC := 0, len(finalMetrics)
|
||||
for _, c := range effective {
|
||||
total += c
|
||||
if c > maxC {
|
||||
maxC = c
|
||||
}
|
||||
if c < minC {
|
||||
minC = c
|
||||
}
|
||||
}
|
||||
|
||||
// Should have balanced the 30 active dc1/rack1 volumes (25+5)
|
||||
if total != 30 {
|
||||
t.Errorf("Expected 30 total filtered volumes, got %d", total)
|
||||
}
|
||||
avg := float64(total) / float64(len(effective))
|
||||
imbalance := float64(maxC-minC) / avg
|
||||
if imbalance > conf.ImbalanceThreshold {
|
||||
t.Errorf("Still imbalanced: effective=%v, imbalance=%.1f%% (threshold=%.1f%%)",
|
||||
effective, imbalance*100, conf.ImbalanceThreshold*100)
|
||||
}
|
||||
|
||||
t.Logf("All factors combined: %d tasks, effective=%v", len(tasks), effective)
|
||||
}
|
||||
|
||||
// TestIntegration_FullVolumesOnlyBalancing verifies that with FULL state filter,
|
||||
// only full volumes participate in balancing.
|
||||
func TestIntegration_FullVolumesOnlyBalancing(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack1"},
|
||||
}
|
||||
|
||||
var allMetrics []*types.VolumeHealthMetrics
|
||||
// node-a: 10 active + 30 full
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 10, withFullness(0.5))...)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 100, 30, withFullness(1.5))...)
|
||||
// node-b: 10 active + 5 full
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 200, 10, withFullness(0.5))...)
|
||||
allMetrics = append(allMetrics, makeVolumesWith("node-b", "hdd", "dc1", "rack1", "c1", 300, 5, withFullness(1.5))...)
|
||||
|
||||
// Filter to FULL only
|
||||
fullMetrics := filterByState(allMetrics, stateFull)
|
||||
|
||||
at := buildTopology(servers, allMetrics)
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: at}
|
||||
|
||||
tasks, _, err := Detection(fullMetrics, clusterInfo, defaultConf(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection failed: %v", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected tasks for 30/5 full volume imbalance")
|
||||
}
|
||||
|
||||
// Verify only full volumes are moved (IDs 100-129 from node-a, 300-304 from node-b)
|
||||
for _, task := range tasks {
|
||||
vid := task.VolumeID
|
||||
// Active volumes are IDs 1-10 (node-a) and 200-209 (node-b)
|
||||
if (vid >= 1 && vid <= 10) || (vid >= 200 && vid <= 209) {
|
||||
t.Errorf("Task moved active volume %d, should only move full volumes", vid)
|
||||
}
|
||||
}
|
||||
|
||||
assertNoDuplicateVolumes(t, tasks)
|
||||
t.Logf("Full-only balancing: %d tasks", len(tasks))
|
||||
}
|
||||
1487
weed/worker/tasks/balance/detection_test.go
Normal file
1487
weed/worker/tasks/balance/detection_test.go
Normal file
File diff suppressed because it is too large
Load Diff
158
weed/worker/tasks/balance/execution.go
Normal file
158
weed/worker/tasks/balance/execution.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// TypedTask implements balance operation with typed protobuf parameters
|
||||
type TypedTask struct {
|
||||
*base.BaseTypedTask
|
||||
|
||||
// Task state from protobuf
|
||||
sourceServer string
|
||||
destNode string
|
||||
volumeID uint32
|
||||
collection string
|
||||
estimatedSize uint64
|
||||
forceMove bool
|
||||
timeoutSeconds int32
|
||||
}
|
||||
|
||||
// NewTypedTask creates a new typed balance task
|
||||
func NewTypedTask() types.TypedTaskInterface {
|
||||
task := &TypedTask{
|
||||
BaseTypedTask: base.NewBaseTypedTask(types.TaskTypeBalance),
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
// ValidateTyped validates the typed parameters for balance task
|
||||
func (t *TypedTask) ValidateTyped(params *worker_pb.TaskParams) error {
|
||||
// Basic validation from base class
|
||||
if err := t.BaseTypedTask.ValidateTyped(params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check that we have balance-specific parameters
|
||||
balanceParams := params.GetBalanceParams()
|
||||
if balanceParams == nil {
|
||||
return fmt.Errorf("balance_params is required for balance task")
|
||||
}
|
||||
|
||||
// Validate sources and targets
|
||||
if len(params.Sources) == 0 {
|
||||
return fmt.Errorf("at least one source is required for balance task")
|
||||
}
|
||||
if len(params.Targets) == 0 {
|
||||
return fmt.Errorf("at least one target is required for balance task")
|
||||
}
|
||||
|
||||
// Validate that source and target have volume IDs
|
||||
if params.Sources[0].VolumeId == 0 {
|
||||
return fmt.Errorf("source volume_id is required for balance task")
|
||||
}
|
||||
if params.Targets[0].VolumeId == 0 {
|
||||
return fmt.Errorf("target volume_id is required for balance task")
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
if balanceParams.TimeoutSeconds <= 0 {
|
||||
return fmt.Errorf("timeout_seconds must be greater than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateTimeTyped estimates the time needed for balance operation based on protobuf parameters
|
||||
func (t *TypedTask) EstimateTimeTyped(params *worker_pb.TaskParams) time.Duration {
|
||||
balanceParams := params.GetBalanceParams()
|
||||
if balanceParams != nil {
|
||||
// Use the timeout from parameters if specified
|
||||
if balanceParams.TimeoutSeconds > 0 {
|
||||
return time.Duration(balanceParams.TimeoutSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate based on volume size from sources (1 minute per GB)
|
||||
if len(params.Sources) > 0 {
|
||||
source := params.Sources[0]
|
||||
if source.EstimatedSize > 0 {
|
||||
gbSize := source.EstimatedSize / (1024 * 1024 * 1024)
|
||||
return time.Duration(gbSize) * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
// Default estimation
|
||||
return 10 * time.Minute
|
||||
}
|
||||
|
||||
// ExecuteTyped implements the balance operation with typed parameters
|
||||
func (t *TypedTask) ExecuteTyped(params *worker_pb.TaskParams) error {
|
||||
// Extract basic parameters
|
||||
t.volumeID = params.VolumeId
|
||||
t.collection = params.Collection
|
||||
|
||||
// Ensure sources and targets are present (should be guaranteed by validation)
|
||||
if len(params.Sources) == 0 {
|
||||
return fmt.Errorf("at least one source is required for balance task (ExecuteTyped)")
|
||||
}
|
||||
if len(params.Targets) == 0 {
|
||||
return fmt.Errorf("at least one target is required for balance task (ExecuteTyped)")
|
||||
}
|
||||
|
||||
// Extract source and target information
|
||||
t.sourceServer = params.Sources[0].Node
|
||||
t.estimatedSize = params.Sources[0].EstimatedSize
|
||||
t.destNode = params.Targets[0].Node
|
||||
// Extract balance-specific parameters
|
||||
balanceParams := params.GetBalanceParams()
|
||||
if balanceParams != nil {
|
||||
t.forceMove = balanceParams.ForceMove
|
||||
t.timeoutSeconds = balanceParams.TimeoutSeconds
|
||||
}
|
||||
|
||||
glog.Infof("Starting typed balance task for volume %d: %s -> %s (collection: %s, size: %d bytes)",
|
||||
t.volumeID, t.sourceServer, t.destNode, t.collection, t.estimatedSize)
|
||||
|
||||
// Simulate balance operation with progress updates
|
||||
steps := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
progress float64
|
||||
}{
|
||||
{"Analyzing cluster state", 2 * time.Second, 15},
|
||||
{"Verifying destination capacity", 1 * time.Second, 25},
|
||||
{"Starting volume migration", 1 * time.Second, 35},
|
||||
{"Moving volume data", 6 * time.Second, 75},
|
||||
{"Updating cluster metadata", 2 * time.Second, 95},
|
||||
{"Verifying balance completion", 1 * time.Second, 100},
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
if t.IsCancelled() {
|
||||
return fmt.Errorf("balance task cancelled during: %s", step.name)
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Balance task step: %s", step.name)
|
||||
t.SetProgress(step.progress)
|
||||
|
||||
// Simulate work
|
||||
time.Sleep(step.duration)
|
||||
}
|
||||
|
||||
glog.Infof("Typed balance task completed successfully for volume %d: %s -> %s",
|
||||
t.volumeID, t.sourceServer, t.destNode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register the typed task in the global registry
|
||||
func init() {
|
||||
types.RegisterGlobalTypedTask(types.TaskTypeBalance, NewTypedTask)
|
||||
glog.V(1).Infof("Registered typed balance task")
|
||||
}
|
||||
1516
weed/worker/tasks/balance/plugin_handler.go
Normal file
1516
weed/worker/tasks/balance/plugin_handler.go
Normal file
File diff suppressed because it is too large
Load Diff
828
weed/worker/tasks/balance/plugin_handler_test.go
Normal file
828
weed/worker/tasks/balance/plugin_handler_test.go
Normal file
@@ -0,0 +1,828 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDecodeVolumeBalanceTaskParamsFromPayload(t *testing.T) {
|
||||
expected := &worker_pb.TaskParams{
|
||||
TaskId: "task-1",
|
||||
VolumeId: 42,
|
||||
Collection: "photos",
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: "10.0.0.1:8080",
|
||||
VolumeId: 42,
|
||||
},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{
|
||||
Node: "10.0.0.2:8080",
|
||||
VolumeId: 42,
|
||||
},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
||||
BalanceParams: &worker_pb.BalanceTaskParams{
|
||||
ForceMove: true,
|
||||
TimeoutSeconds: 1200,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := proto.Marshal(expected)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-from-admin",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
actual, err := decodeVolumeBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeVolumeBalanceTaskParams() err = %v", err)
|
||||
}
|
||||
if !proto.Equal(expected, actual) {
|
||||
t.Fatalf("decoded params mismatch\nexpected: %+v\nactual: %+v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeVolumeBalanceTaskParamsFallback(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-2",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.1:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.2:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "videos"}},
|
||||
},
|
||||
}
|
||||
|
||||
params, err := decodeVolumeBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeVolumeBalanceTaskParams() err = %v", err)
|
||||
}
|
||||
if params.TaskId != "job-2" || params.VolumeId != 7 || params.Collection != "videos" {
|
||||
t.Fatalf("unexpected basic params: %+v", params)
|
||||
}
|
||||
if len(params.Sources) != 1 || params.Sources[0].Node != "127.0.0.1:8080" {
|
||||
t.Fatalf("unexpected sources: %+v", params.Sources)
|
||||
}
|
||||
if len(params.Targets) != 1 || params.Targets[0].Node != "127.0.0.2:8080" {
|
||||
t.Fatalf("unexpected targets: %+v", params.Targets)
|
||||
}
|
||||
if params.GetBalanceParams() == nil {
|
||||
t.Fatalf("expected fallback balance params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveBalanceWorkerConfig(t *testing.T) {
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.45},
|
||||
},
|
||||
"min_server_count": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 5},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := deriveBalanceWorkerConfig(values)
|
||||
if cfg.TaskConfig.ImbalanceThreshold != 0.45 {
|
||||
t.Fatalf("expected imbalance_threshold 0.45, got %v", cfg.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if cfg.TaskConfig.MinServerCount != 5 {
|
||||
t.Fatalf("expected min_server_count 5, got %d", cfg.TaskConfig.MinServerCount)
|
||||
}
|
||||
// Defaults for batch config when not specified
|
||||
if cfg.MaxConcurrentMoves != defaultMaxConcurrentMoves {
|
||||
t.Fatalf("expected default max_concurrent_moves %d, got %d", defaultMaxConcurrentMoves, cfg.MaxConcurrentMoves)
|
||||
}
|
||||
if cfg.BatchSize != 20 {
|
||||
t.Fatalf("expected default batch_size 20, got %d", cfg.BatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveBalanceWorkerConfigBatchFields(t *testing.T) {
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"max_concurrent_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 10},
|
||||
},
|
||||
"batch_size": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 50},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := deriveBalanceWorkerConfig(values)
|
||||
if cfg.MaxConcurrentMoves != 10 {
|
||||
t.Fatalf("expected max_concurrent_moves 10, got %d", cfg.MaxConcurrentMoves)
|
||||
}
|
||||
if cfg.BatchSize != 50 {
|
||||
t.Fatalf("expected batch_size 50, got %d", cfg.BatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveBalanceWorkerConfigBatchClamping(t *testing.T) {
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"max_concurrent_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 999},
|
||||
},
|
||||
"batch_size": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := deriveBalanceWorkerConfig(values)
|
||||
if cfg.MaxConcurrentMoves != 50 {
|
||||
t.Fatalf("expected max_concurrent_moves clamped to 50, got %d", cfg.MaxConcurrentMoves)
|
||||
}
|
||||
if cfg.BatchSize != 1 {
|
||||
t.Fatalf("expected batch_size clamped to 1, got %d", cfg.BatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func makeDetectionResult(volumeID uint32, source, target, collection string) *workertypes.TaskDetectionResult {
|
||||
return &workertypes.TaskDetectionResult{
|
||||
TaskID: fmt.Sprintf("balance-%d", volumeID),
|
||||
TaskType: workertypes.TaskTypeBalance,
|
||||
VolumeID: volumeID,
|
||||
Server: source,
|
||||
Collection: collection,
|
||||
Priority: workertypes.TaskPriorityNormal,
|
||||
Reason: "imbalanced",
|
||||
TypedParams: &worker_pb.TaskParams{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
VolumeSize: 1024,
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{Node: source, VolumeId: volumeID},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{Node: target, VolumeId: volumeID},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
||||
BalanceParams: &worker_pb.BalanceTaskParams{TimeoutSeconds: 600},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchVolumeBalanceProposals_SingleBatch(t *testing.T) {
|
||||
results := []*workertypes.TaskDetectionResult{
|
||||
makeDetectionResult(1, "s1:8080", "t1:8080", "c1"),
|
||||
makeDetectionResult(2, "s2:8080", "t2:8080", "c1"),
|
||||
makeDetectionResult(3, "s1:8080", "t2:8080", "c1"),
|
||||
}
|
||||
|
||||
proposals := buildBatchVolumeBalanceProposals(results, 10, 5)
|
||||
if len(proposals) != 1 {
|
||||
t.Fatalf("expected 1 batch proposal, got %d", len(proposals))
|
||||
}
|
||||
|
||||
p := proposals[0]
|
||||
if p.Labels["batch"] != "true" {
|
||||
t.Fatalf("expected batch label")
|
||||
}
|
||||
if p.Labels["batch_size"] != "3" {
|
||||
t.Fatalf("expected batch_size label '3', got %q", p.Labels["batch_size"])
|
||||
}
|
||||
|
||||
// Decode and verify moves
|
||||
payload := p.Parameters["task_params_pb"].GetBytesValue()
|
||||
if len(payload) == 0 {
|
||||
t.Fatalf("expected task_params_pb payload")
|
||||
}
|
||||
decoded := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
moves := decoded.GetBalanceParams().GetMoves()
|
||||
if len(moves) != 3 {
|
||||
t.Fatalf("expected 3 moves, got %d", len(moves))
|
||||
}
|
||||
if moves[0].VolumeId != 1 || moves[1].VolumeId != 2 || moves[2].VolumeId != 3 {
|
||||
t.Fatalf("unexpected volume IDs: %v", moves)
|
||||
}
|
||||
if decoded.GetBalanceParams().MaxConcurrentMoves != 5 {
|
||||
t.Fatalf("expected MaxConcurrentMoves 5, got %d", decoded.GetBalanceParams().MaxConcurrentMoves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchVolumeBalanceProposals_MultipleBatches(t *testing.T) {
|
||||
results := make([]*workertypes.TaskDetectionResult, 5)
|
||||
for i := range results {
|
||||
results[i] = makeDetectionResult(uint32(i+1), "s1:8080", "t1:8080", "c1")
|
||||
}
|
||||
|
||||
proposals := buildBatchVolumeBalanceProposals(results, 2, 3)
|
||||
// 5 results / batch_size 2 = 3 proposals (2, 2, 1)
|
||||
if len(proposals) != 3 {
|
||||
t.Fatalf("expected 3 proposals, got %d", len(proposals))
|
||||
}
|
||||
|
||||
// First two should be batch proposals
|
||||
if proposals[0].Labels["batch"] != "true" {
|
||||
t.Fatalf("first proposal should be batch")
|
||||
}
|
||||
if proposals[1].Labels["batch"] != "true" {
|
||||
t.Fatalf("second proposal should be batch")
|
||||
}
|
||||
// Last one has only 1 result, should fall back to single-move proposal
|
||||
if proposals[2].Labels["batch"] == "true" {
|
||||
t.Fatalf("last proposal with 1 result should be single-move, not batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchVolumeBalanceProposals_BatchSizeOne(t *testing.T) {
|
||||
results := []*workertypes.TaskDetectionResult{
|
||||
makeDetectionResult(1, "s1:8080", "t1:8080", "c1"),
|
||||
makeDetectionResult(2, "s2:8080", "t2:8080", "c1"),
|
||||
}
|
||||
|
||||
// batch_size=1 should not be called (Detect guards this), but test the function directly
|
||||
proposals := buildBatchVolumeBalanceProposals(results, 1, 5)
|
||||
// Each result becomes its own single-move proposal
|
||||
if len(proposals) != 2 {
|
||||
t.Fatalf("expected 2 proposals, got %d", len(proposals))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeBalanceDescriptorHasBatchFields(t *testing.T) {
|
||||
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
|
||||
if !workerConfigFormHasField(descriptor.WorkerConfigForm, "max_concurrent_moves") {
|
||||
t.Fatalf("expected max_concurrent_moves in worker config form")
|
||||
}
|
||||
if !workerConfigFormHasField(descriptor.WorkerConfigForm, "batch_size") {
|
||||
t.Fatalf("expected batch_size in worker config form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeBalanceProposal(t *testing.T) {
|
||||
params := &worker_pb.TaskParams{
|
||||
TaskId: "balance-task-1",
|
||||
VolumeId: 55,
|
||||
Collection: "images",
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: "source-a:8080",
|
||||
VolumeId: 55,
|
||||
},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{
|
||||
Node: "target-b:8080",
|
||||
VolumeId: 55,
|
||||
},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
||||
BalanceParams: &worker_pb.BalanceTaskParams{
|
||||
TimeoutSeconds: 600,
|
||||
},
|
||||
},
|
||||
}
|
||||
result := &workertypes.TaskDetectionResult{
|
||||
TaskID: "balance-task-1",
|
||||
TaskType: workertypes.TaskTypeBalance,
|
||||
VolumeID: 55,
|
||||
Server: "source-a",
|
||||
Collection: "images",
|
||||
Priority: workertypes.TaskPriorityHigh,
|
||||
Reason: "imbalanced load",
|
||||
TypedParams: params,
|
||||
}
|
||||
|
||||
proposal, err := buildVolumeBalanceProposal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("buildVolumeBalanceProposal() err = %v", err)
|
||||
}
|
||||
if proposal.JobType != "volume_balance" {
|
||||
t.Fatalf("unexpected job type %q", proposal.JobType)
|
||||
}
|
||||
if proposal.DedupeKey == "" {
|
||||
t.Fatalf("expected dedupe key")
|
||||
}
|
||||
if proposal.Parameters["task_params_pb"] == nil {
|
||||
t.Fatalf("expected serialized task params")
|
||||
}
|
||||
if proposal.Labels["source_node"] != "source-a:8080" {
|
||||
t.Fatalf("unexpected source label %q", proposal.Labels["source_node"])
|
||||
}
|
||||
if proposal.Labels["target_node"] != "target-b:8080" {
|
||||
t.Fatalf("unexpected target label %q", proposal.Labels["target_node"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeBalanceHandlerRejectsUnsupportedJobType(t *testing.T) {
|
||||
handler := NewVolumeBalanceHandler(nil)
|
||||
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: "vacuum",
|
||||
}, noopDetectionSender{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected detect job type mismatch error")
|
||||
}
|
||||
|
||||
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobId: "job-1", JobType: "vacuum"},
|
||||
}, noopExecutionSender{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected execute job type mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitVolumeBalanceDetectionDecisionTraceNoTasks(t *testing.T) {
|
||||
sender := &recordingDetectionSender{}
|
||||
config := NewDefaultConfig()
|
||||
config.ImbalanceThreshold = 0.2
|
||||
config.MinServerCount = 2
|
||||
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, Server: "server-a", DiskType: "hdd"},
|
||||
{VolumeID: 2, Server: "server-a", DiskType: "hdd"},
|
||||
{VolumeID: 3, Server: "server-b", DiskType: "hdd"},
|
||||
{VolumeID: 4, Server: "server-b", DiskType: "hdd"},
|
||||
}
|
||||
|
||||
if err := emitVolumeBalanceDetectionDecisionTrace(sender, metrics, nil, config, nil); err != nil {
|
||||
t.Fatalf("emitVolumeBalanceDetectionDecisionTrace error: %v", err)
|
||||
}
|
||||
if len(sender.events) < 2 {
|
||||
t.Fatalf("expected at least 2 detection events, got %d", len(sender.events))
|
||||
}
|
||||
if sender.events[0].Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR {
|
||||
t.Fatalf("expected detector source, got %v", sender.events[0].Source)
|
||||
}
|
||||
if !strings.Contains(sender.events[0].Message, "BALANCE: No tasks created for 4 volumes") {
|
||||
t.Fatalf("unexpected summary message: %q", sender.events[0].Message)
|
||||
}
|
||||
foundDiskTypeDecision := false
|
||||
for _, event := range sender.events {
|
||||
if strings.Contains(event.Message, "BALANCE [hdd]: No tasks created - cluster well balanced") {
|
||||
foundDiskTypeDecision = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDiskTypeDecision {
|
||||
t.Fatalf("expected per-disk-type decision message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeBalanceDescriptorOmitsExecutionTuningFields(t *testing.T) {
|
||||
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
|
||||
if descriptor == nil || descriptor.WorkerConfigForm == nil {
|
||||
t.Fatalf("expected worker config form in descriptor")
|
||||
}
|
||||
if workerConfigFormHasField(descriptor.WorkerConfigForm, "timeout_seconds") {
|
||||
t.Fatalf("unexpected timeout_seconds in volume balance worker config form")
|
||||
}
|
||||
if workerConfigFormHasField(descriptor.WorkerConfigForm, "force_move") {
|
||||
t.Fatalf("unexpected force_move in volume balance worker config form")
|
||||
}
|
||||
}
|
||||
|
||||
type recordingExecutionSender struct {
|
||||
mu sync.Mutex
|
||||
progress []*plugin_pb.JobProgressUpdate
|
||||
completed *plugin_pb.JobCompleted
|
||||
}
|
||||
|
||||
func (r *recordingExecutionSender) SendProgress(p *plugin_pb.JobProgressUpdate) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.progress = append(r.progress, proto.Clone(p).(*plugin_pb.JobProgressUpdate))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingExecutionSender) SendCompleted(c *plugin_pb.JobCompleted) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.completed = proto.Clone(c).(*plugin_pb.JobCompleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBuildMoveTaskParams(t *testing.T) {
|
||||
move := &worker_pb.BalanceMoveSpec{
|
||||
VolumeId: 42,
|
||||
SourceNode: "10.0.0.1:8080",
|
||||
TargetNode: "10.0.0.2:8080",
|
||||
Collection: "photos",
|
||||
VolumeSize: 1024 * 1024,
|
||||
}
|
||||
|
||||
outerParams := &worker_pb.BalanceTaskParams{
|
||||
ForceMove: true,
|
||||
TimeoutSeconds: 300,
|
||||
}
|
||||
params := buildMoveTaskParams(move, outerParams)
|
||||
if params.VolumeId != 42 {
|
||||
t.Fatalf("expected volume_id 42, got %d", params.VolumeId)
|
||||
}
|
||||
if params.Collection != "photos" {
|
||||
t.Fatalf("expected collection photos, got %s", params.Collection)
|
||||
}
|
||||
if params.VolumeSize != 1024*1024 {
|
||||
t.Fatalf("expected volume_size %d, got %d", 1024*1024, params.VolumeSize)
|
||||
}
|
||||
if len(params.Sources) != 1 || params.Sources[0].Node != "10.0.0.1:8080" {
|
||||
t.Fatalf("unexpected sources: %+v", params.Sources)
|
||||
}
|
||||
if len(params.Targets) != 1 || params.Targets[0].Node != "10.0.0.2:8080" {
|
||||
t.Fatalf("unexpected targets: %+v", params.Targets)
|
||||
}
|
||||
bp := params.GetBalanceParams()
|
||||
if bp == nil {
|
||||
t.Fatalf("expected balance params")
|
||||
}
|
||||
if bp.TimeoutSeconds != 300 {
|
||||
t.Fatalf("expected timeout 300, got %d", bp.TimeoutSeconds)
|
||||
}
|
||||
if !bp.ForceMove {
|
||||
t.Fatalf("expected force_move to be propagated from outer params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMoveTaskParamsDefaultTimeout(t *testing.T) {
|
||||
move := &worker_pb.BalanceMoveSpec{
|
||||
VolumeId: 1,
|
||||
SourceNode: "a:8080",
|
||||
TargetNode: "b:8080",
|
||||
}
|
||||
params := buildMoveTaskParams(move, nil)
|
||||
if params.GetBalanceParams().TimeoutSeconds != defaultBalanceTimeoutSeconds {
|
||||
t.Fatalf("expected default timeout %d, got %d", defaultBalanceTimeoutSeconds, params.GetBalanceParams().TimeoutSeconds)
|
||||
}
|
||||
if params.GetBalanceParams().ForceMove {
|
||||
t.Fatalf("expected force_move to default to false with nil outer params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDispatchesBatchPath(t *testing.T) {
|
||||
// Build a job with batch moves in BalanceTaskParams
|
||||
bp := &worker_pb.BalanceTaskParams{
|
||||
TimeoutSeconds: 60,
|
||||
MaxConcurrentMoves: 2,
|
||||
Moves: []*worker_pb.BalanceMoveSpec{
|
||||
{VolumeId: 1, SourceNode: "s1:8080", TargetNode: "t1:8080", Collection: "c1"},
|
||||
{VolumeId: 2, SourceNode: "s2:8080", TargetNode: "t2:8080", Collection: "c1"},
|
||||
},
|
||||
}
|
||||
taskParams := &worker_pb.TaskParams{
|
||||
TaskId: "batch-1",
|
||||
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
||||
BalanceParams: bp,
|
||||
},
|
||||
}
|
||||
payload, err := proto.Marshal(taskParams)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "batch-job-1",
|
||||
JobType: "volume_balance",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVolumeBalanceHandler(nil)
|
||||
sender := &recordingExecutionSender{}
|
||||
|
||||
// Execute will enter the batch path. It will fail because there's no real gRPC server,
|
||||
// but we verify it sends the assigned progress and eventually a completion.
|
||||
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: job,
|
||||
}, sender)
|
||||
if err != nil {
|
||||
t.Fatalf("handler.Execute: %v", err)
|
||||
}
|
||||
|
||||
// Expect an error since no real volume servers exist
|
||||
// But verify the batch path was taken by checking the assigned message
|
||||
sender.mu.Lock()
|
||||
defer sender.mu.Unlock()
|
||||
|
||||
if len(sender.progress) == 0 {
|
||||
t.Fatalf("expected progress messages from batch path")
|
||||
}
|
||||
|
||||
// First progress should be "assigned" with batch info
|
||||
first := sender.progress[0]
|
||||
if first.Stage != "assigned" {
|
||||
t.Fatalf("expected first stage 'assigned', got %q", first.Stage)
|
||||
}
|
||||
if !strings.Contains(first.Message, "batch") || !strings.Contains(first.Message, "2 moves") {
|
||||
t.Fatalf("expected batch assigned message, got %q", first.Message)
|
||||
}
|
||||
|
||||
// Should have a completion with failure details (since no servers)
|
||||
if sender.completed == nil {
|
||||
t.Fatalf("expected completion message")
|
||||
}
|
||||
if sender.completed.Success {
|
||||
t.Fatalf("expected failure since no real gRPC servers")
|
||||
}
|
||||
// Should report 0 succeeded, 2 failed
|
||||
if v, ok := sender.completed.Result.OutputValues["failed"]; !ok || v.GetInt64Value() != 2 {
|
||||
t.Fatalf("expected 2 failed moves, got %+v", sender.completed.Result.OutputValues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSingleMovePathUnchanged(t *testing.T) {
|
||||
// Build a single-move job (no batch moves)
|
||||
taskParams := &worker_pb.TaskParams{
|
||||
TaskId: "single-1",
|
||||
VolumeId: 99,
|
||||
Collection: "videos",
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{Node: "src:8080", VolumeId: 99},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{Node: "dst:8080", VolumeId: 99},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_BalanceParams{
|
||||
BalanceParams: &worker_pb.BalanceTaskParams{
|
||||
TimeoutSeconds: 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := proto.Marshal(taskParams)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "single-job-1",
|
||||
JobType: "volume_balance",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVolumeBalanceHandler(nil)
|
||||
sender := &recordingExecutionSender{}
|
||||
|
||||
// Execute single-move path. Will fail on gRPC but verify it takes the single-move path.
|
||||
_ = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: job,
|
||||
}, sender)
|
||||
|
||||
sender.mu.Lock()
|
||||
defer sender.mu.Unlock()
|
||||
|
||||
if len(sender.progress) == 0 {
|
||||
t.Fatalf("expected progress messages")
|
||||
}
|
||||
|
||||
// Single-move path sends "volume balance job accepted" not "batch volume balance"
|
||||
first := sender.progress[0]
|
||||
if first.Stage != "assigned" {
|
||||
t.Fatalf("expected first stage 'assigned', got %q", first.Stage)
|
||||
}
|
||||
if strings.Contains(first.Message, "batch") {
|
||||
t.Fatalf("single-move path should not mention batch, got %q", first.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByLocation(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, Server: "node-a", DataCenter: "dc1", Rack: "rack1"},
|
||||
{VolumeID: 2, Server: "node-b", DataCenter: "dc1", Rack: "rack2"},
|
||||
{VolumeID: 3, Server: "node-c", DataCenter: "dc2", Rack: "rack1"},
|
||||
{VolumeID: 4, Server: "node-d", DataCenter: "dc2", Rack: "rack3"},
|
||||
}
|
||||
|
||||
// Filter by DC
|
||||
filtered := pluginworker.FilterMetricsByLocation(metrics, "dc1", "", "")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("DC filter: expected 2, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Filter by rack
|
||||
filtered = pluginworker.FilterMetricsByLocation(metrics, "", "rack1,rack2", "")
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("rack filter: expected 3, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Filter by node
|
||||
filtered = pluginworker.FilterMetricsByLocation(metrics, "", "", "node-a,node-c")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("node filter: expected 2, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Combined DC + rack
|
||||
filtered = pluginworker.FilterMetricsByLocation(metrics, "dc2", "rack3", "")
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("DC+rack filter: expected 1, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Empty filters pass all
|
||||
filtered = pluginworker.FilterMetricsByLocation(metrics, "", "", "")
|
||||
if len(filtered) != 4 {
|
||||
t.Fatalf("no filter: expected 4, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||
{VolumeID: 2, FullnessRatio: 1.0}, // active (below 1.01)
|
||||
{VolumeID: 3, FullnessRatio: 1.009}, // active (below 1.01)
|
||||
{VolumeID: 4, FullnessRatio: 1.01}, // full (exactly at threshold)
|
||||
{VolumeID: 5, FullnessRatio: 1.5}, // full
|
||||
{VolumeID: 6, FullnessRatio: 2.0}, // full
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
expectedIDs []uint32
|
||||
}{
|
||||
{
|
||||
name: "ALL returns everything",
|
||||
state: "ALL",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "empty string returns everything",
|
||||
state: "",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "ACTIVE keeps FullnessRatio below 1.01",
|
||||
state: "ACTIVE",
|
||||
expectedIDs: []uint32{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "FULL keeps FullnessRatio at or above 1.01",
|
||||
state: "FULL",
|
||||
expectedIDs: []uint32{4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "unknown value returns everything",
|
||||
state: "INVALID",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := pluginworker.FilterMetricsByVolumeState(metrics, pluginworker.VolumeState(tt.state))
|
||||
if len(result) != len(tt.expectedIDs) {
|
||||
t.Fatalf("expected %d metrics, got %d", len(tt.expectedIDs), len(result))
|
||||
}
|
||||
for i, m := range result {
|
||||
if m.VolumeID != tt.expectedIDs[i] {
|
||||
t.Errorf("result[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.expectedIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState_NilElement(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
nil,
|
||||
{VolumeID: 1, FullnessRatio: 0.5},
|
||||
nil,
|
||||
{VolumeID: 2, FullnessRatio: 1.5},
|
||||
}
|
||||
result := pluginworker.FilterMetricsByVolumeState(metrics, pluginworker.VolumeStateActive)
|
||||
if len(result) != 1 || result[0].VolumeID != 1 {
|
||||
t.Fatalf("expected [vol 1] for ACTIVE with nil elements, got %d results", len(result))
|
||||
}
|
||||
result = pluginworker.FilterMetricsByVolumeState(metrics, pluginworker.VolumeStateFull)
|
||||
if len(result) != 1 || result[0].VolumeID != 2 {
|
||||
t.Fatalf("expected [vol 2] for FULL with nil elements, got %d results", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState_EmptyInput(t *testing.T) {
|
||||
result := pluginworker.FilterMetricsByVolumeState(nil, pluginworker.VolumeStateActive)
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 metrics for nil input, got %d", len(result))
|
||||
}
|
||||
|
||||
result = pluginworker.FilterMetricsByVolumeState([]*workertypes.VolumeHealthMetrics{}, pluginworker.VolumeStateFull)
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 metrics for empty input, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeBalanceDescriptorHasVolumeStateField(t *testing.T) {
|
||||
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
|
||||
if descriptor == nil || descriptor.AdminConfigForm == nil {
|
||||
t.Fatalf("expected admin config form in descriptor")
|
||||
}
|
||||
found := false
|
||||
for _, section := range descriptor.AdminConfigForm.Sections {
|
||||
for _, field := range section.Fields {
|
||||
if field.Name == "volume_state" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected volume_state field in admin config form")
|
||||
}
|
||||
defaultVal, ok := descriptor.AdminConfigForm.DefaultValues["volume_state"]
|
||||
if !ok {
|
||||
t.Fatalf("expected volume_state default value")
|
||||
}
|
||||
if defaultVal.GetStringValue() != "ALL" {
|
||||
t.Fatalf("expected volume_state default 'ALL', got %q", defaultVal.GetStringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
|
||||
if form == nil {
|
||||
return false
|
||||
}
|
||||
for _, section := range form.Sections {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
for _, field := range section.Fields {
|
||||
if field != nil && field.Name == fieldName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type noopDetectionSender struct{}
|
||||
|
||||
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
|
||||
func (noopDetectionSender) SendComplete(*plugin_pb.DetectionComplete) error { return nil }
|
||||
func (noopDetectionSender) SendActivity(*plugin_pb.ActivityEvent) error { return nil }
|
||||
|
||||
type noopExecutionSender struct{}
|
||||
|
||||
func (noopExecutionSender) SendProgress(*plugin_pb.JobProgressUpdate) error { return nil }
|
||||
func (noopExecutionSender) SendCompleted(*plugin_pb.JobCompleted) error { return nil }
|
||||
|
||||
type recordingDetectionSender struct {
|
||||
proposals *plugin_pb.DetectionProposals
|
||||
complete *plugin_pb.DetectionComplete
|
||||
events []*plugin_pb.ActivityEvent
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendProposals(proposals *plugin_pb.DetectionProposals) error {
|
||||
r.proposals = proposals
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendComplete(complete *plugin_pb.DetectionComplete) error {
|
||||
r.complete = complete
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendActivity(event *plugin_pb.ActivityEvent) error {
|
||||
if event != nil {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCheckMovePreconditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
locations []string
|
||||
sourceNode string
|
||||
targetNode string
|
||||
wantErr string
|
||||
}{
|
||||
{"valid move", []string{"10.0.0.1:8080", "10.0.0.3:8080"}, "10.0.0.1:8080", "10.0.0.2:8080", ""},
|
||||
{"volume left the source", []string{"10.0.0.3:8080"}, "10.0.0.1:8080", "10.0.0.2:8080", "no longer on source"},
|
||||
{"volume gone entirely", nil, "10.0.0.1:8080", "10.0.0.2:8080", "no longer on source"},
|
||||
{"target already has a replica", []string{"10.0.0.1:8080", "10.0.0.2:8080"}, "10.0.0.1:8080", "10.0.0.2:8080", "already has a replica on target"},
|
||||
{"proposal nodes carry grpc suffix", []string{"10.0.0.1:8080", "10.0.0.3:8080"}, "10.0.0.1:8080.18080", "10.0.0.2:8080.18080", ""},
|
||||
{"grpc-suffixed target already has a replica", []string{"10.0.0.1:8080", "10.0.0.2:8080"}, "10.0.0.1:8080.18080", "10.0.0.2:8080.18080", "already has a replica on target"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := checkMovePreconditions(tt.locations, 5, tt.sourceNode, tt.targetNode)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
95
weed/worker/tasks/balance/register.go
Normal file
95
weed/worker/tasks/balance/register.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Global variable to hold the task definition for configuration updates
|
||||
var globalTaskDef *base.TaskDefinition
|
||||
|
||||
// Auto-register this task when the package is imported
|
||||
func init() {
|
||||
RegisterBalanceTask()
|
||||
|
||||
// Register config updater
|
||||
tasks.AutoRegisterConfigUpdater(types.TaskTypeBalance, UpdateConfigFromPersistence)
|
||||
}
|
||||
|
||||
// RegisterBalanceTask registers the balance task with the new architecture
|
||||
func RegisterBalanceTask() {
|
||||
// Create configuration instance
|
||||
config := NewDefaultConfig()
|
||||
|
||||
// Create shared gRPC dial option using TLS configuration
|
||||
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
|
||||
|
||||
// Create complete task definition
|
||||
taskDef := &base.TaskDefinition{
|
||||
Type: types.TaskTypeBalance,
|
||||
Name: "balance",
|
||||
DisplayName: "Volume Balance",
|
||||
Description: "Balances volume distribution across servers",
|
||||
Icon: "fas fa-balance-scale text-warning",
|
||||
Capabilities: []string{"balance", "distribution"},
|
||||
|
||||
Config: config,
|
||||
ConfigSpec: GetConfigSpec(),
|
||||
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
|
||||
if params == nil {
|
||||
return nil, fmt.Errorf("task parameters are required")
|
||||
}
|
||||
if len(params.Sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required for balance task")
|
||||
}
|
||||
return NewBalanceTask(
|
||||
fmt.Sprintf("balance-%d", params.VolumeId),
|
||||
params.Sources[0].Node, // Use first source node
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
dialOpt,
|
||||
), nil
|
||||
},
|
||||
DetectionFunc: func(metrics []*types.VolumeHealthMetrics, info *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
|
||||
results, _, err := Detection(metrics, info, config, 0)
|
||||
return results, err
|
||||
},
|
||||
ScanInterval: 30 * time.Minute,
|
||||
SchedulingFunc: Scheduling,
|
||||
MaxConcurrent: 1,
|
||||
RepeatInterval: 2 * time.Hour,
|
||||
}
|
||||
|
||||
// Store task definition globally for configuration updates
|
||||
globalTaskDef = taskDef
|
||||
|
||||
// Register everything with a single function call!
|
||||
base.RegisterTask(taskDef)
|
||||
}
|
||||
|
||||
// UpdateConfigFromPersistence updates the balance configuration from persistence
|
||||
func UpdateConfigFromPersistence(configPersistence interface{}) error {
|
||||
if globalTaskDef == nil {
|
||||
return fmt.Errorf("balance task not registered")
|
||||
}
|
||||
|
||||
// Load configuration from persistence
|
||||
newConfig := LoadConfigFromPersistence(configPersistence)
|
||||
if newConfig == nil {
|
||||
return fmt.Errorf("failed to load configuration from persistence")
|
||||
}
|
||||
|
||||
// Update the task definition's config
|
||||
globalTaskDef.Config = newConfig
|
||||
|
||||
glog.V(1).Infof("Updated balance task configuration from persistence")
|
||||
return nil
|
||||
}
|
||||
37
weed/worker/tasks/balance/replica_placement.go
Normal file
37
weed/worker/tasks/balance/replica_placement.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// hostFromAddress returns the physical machine (host/IP) of a server address,
|
||||
// falling back to the node id when no address is known.
|
||||
func hostFromAddress(address, nodeID string) string {
|
||||
return balancer.HostFromAddress(address, nodeID)
|
||||
}
|
||||
|
||||
// IsGoodMove checks whether moving a volume from sourceNodeID to target would
|
||||
// satisfy the volume's replica placement policy. It is a thin adapter over the
|
||||
// shared placement logic in weed/topology/balancer so the shell command and this
|
||||
// worker cannot drift.
|
||||
func IsGoodMove(rp *super_block.ReplicaPlacement, existingReplicas []types.ReplicaLocation, sourceNodeID string, target types.ReplicaLocation) bool {
|
||||
locs := make([]balancer.Location, len(existingReplicas))
|
||||
for i, r := range existingReplicas {
|
||||
locs[i] = toBalancerLocation(r)
|
||||
}
|
||||
return balancer.IsGoodMove(rp, locs, sourceNodeID, toBalancerLocation(target))
|
||||
}
|
||||
|
||||
// toBalancerLocation copies a worker replica location to the shared placement
|
||||
// type field-by-field, so a future change to either struct is a compile error
|
||||
// here rather than a silent mismatch.
|
||||
func toBalancerLocation(r types.ReplicaLocation) balancer.Location {
|
||||
return balancer.Location{
|
||||
DataCenter: r.DataCenter,
|
||||
Rack: r.Rack,
|
||||
NodeID: r.NodeID,
|
||||
Host: r.Host,
|
||||
}
|
||||
}
|
||||
256
weed/worker/tasks/balance/replica_placement_test.go
Normal file
256
weed/worker/tasks/balance/replica_placement_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
func rp(t *testing.T, code string) *super_block.ReplicaPlacement {
|
||||
t.Helper()
|
||||
r, err := super_block.NewReplicaPlacementFromString(code)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid replica placement code %q: %v", code, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func loc(dc, rack, node string) types.ReplicaLocation {
|
||||
return types.ReplicaLocation{DataCenter: dc, Rack: rack, NodeID: node}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_NoReplication(t *testing.T) {
|
||||
// 000 = no replication. Any move is fine.
|
||||
if !IsGoodMove(rp(t, "000"), []types.ReplicaLocation{loc("dc1", "r1", "n1")}, "n1", loc("dc1", "r1", "n2")) {
|
||||
t.Error("000: any move should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_MachineAntiAffinity(t *testing.T) {
|
||||
// rep 001 allows two copies in one rack, so replica placement alone would permit
|
||||
// moving onto another port of a host that already holds a replica; machine
|
||||
// anti-affinity must reject that and allow a distinct host.
|
||||
existing := []types.ReplicaLocation{
|
||||
{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.1:8080", Host: "10.0.0.1"},
|
||||
{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.2:8080", Host: "10.0.0.2"},
|
||||
}
|
||||
onSameMachine := types.ReplicaLocation{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.1:8081", Host: "10.0.0.1"}
|
||||
if IsGoodMove(rp(t, "001"), existing, "10.0.0.2:8080", onSameMachine) {
|
||||
t.Error("move onto a machine already holding a replica should be rejected")
|
||||
}
|
||||
onOtherMachine := types.ReplicaLocation{DataCenter: "dc1", Rack: "r1", NodeID: "10.0.0.3:8080", Host: "10.0.0.3"}
|
||||
if !IsGoodMove(rp(t, "001"), existing, "10.0.0.2:8080", onOtherMachine) {
|
||||
t.Error("move onto a distinct machine should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_001_SameRack(t *testing.T) {
|
||||
// 001 = 1 replica on same rack (2 total on same rack)
|
||||
existing := []types.ReplicaLocation{
|
||||
loc("dc1", "r1", "n1"),
|
||||
loc("dc1", "r1", "n2"),
|
||||
}
|
||||
// Move n1 -> n3 on same rack: good
|
||||
if !IsGoodMove(rp(t, "001"), existing, "n1", loc("dc1", "r1", "n3")) {
|
||||
t.Error("001: move to same rack should be allowed")
|
||||
}
|
||||
// Move n1 -> n3 on different rack: bad (would leave only 1 on r1, need 2)
|
||||
if IsGoodMove(rp(t, "001"), existing, "n1", loc("dc1", "r2", "n3")) {
|
||||
t.Error("001: move to different rack should not be allowed when it breaks same-rack count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_010_DiffRack(t *testing.T) {
|
||||
// 010 = 1 replica on different rack (2 racks total)
|
||||
existing := []types.ReplicaLocation{
|
||||
loc("dc1", "r1", "n1"),
|
||||
loc("dc1", "r2", "n2"),
|
||||
}
|
||||
// Move n1 -> n3 on r2: bad (both replicas on same rack)
|
||||
if IsGoodMove(rp(t, "010"), existing, "n1", loc("dc1", "r2", "n3")) {
|
||||
t.Error("010: move to same rack as other replica should not be allowed")
|
||||
}
|
||||
// Move n1 -> n3 on r3: good (still 2 different racks)
|
||||
if !IsGoodMove(rp(t, "010"), existing, "n1", loc("dc1", "r3", "n3")) {
|
||||
t.Error("010: move to different rack should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_100_DiffDC(t *testing.T) {
|
||||
// 100 = 1 replica in different DC
|
||||
existing := []types.ReplicaLocation{
|
||||
loc("dc1", "r1", "n1"),
|
||||
loc("dc2", "r1", "n2"),
|
||||
}
|
||||
// Move n1 -> n3 in dc2: bad (both in same DC)
|
||||
if IsGoodMove(rp(t, "100"), existing, "n1", loc("dc2", "r1", "n3")) {
|
||||
t.Error("100: move to same DC as other replica should not be allowed")
|
||||
}
|
||||
// Move n1 -> n3 in dc3: good (different DCs)
|
||||
if !IsGoodMove(rp(t, "100"), existing, "n1", loc("dc3", "r1", "n3")) {
|
||||
t.Error("100: move to different DC should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_SameNode(t *testing.T) {
|
||||
// Moving to the same node as an existing replica should always be rejected
|
||||
existing := []types.ReplicaLocation{
|
||||
loc("dc1", "r1", "n1"),
|
||||
loc("dc1", "r2", "n2"),
|
||||
}
|
||||
if IsGoodMove(rp(t, "010"), existing, "n1", loc("dc1", "r2", "n2")) {
|
||||
t.Error("should reject move to same node as existing replica")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_011_Composite(t *testing.T) {
|
||||
// 011 = 1 same-rack + 1 different-rack (3 replicas: 2 on same rack, 1 on different)
|
||||
existing := []types.ReplicaLocation{
|
||||
loc("dc1", "r1", "n1"),
|
||||
loc("dc1", "r1", "n2"),
|
||||
loc("dc1", "r2", "n3"),
|
||||
}
|
||||
// Move n1 -> n4 on r1: good (maintains 2 on r1, 1 on r2)
|
||||
if !IsGoodMove(rp(t, "011"), existing, "n1", loc("dc1", "r1", "n4")) {
|
||||
t.Error("011: move within same rack should be allowed")
|
||||
}
|
||||
// Move n3 -> n4 on r1: bad (would have 3 on r1, 0 on different rack)
|
||||
if IsGoodMove(rp(t, "011"), existing, "n3", loc("dc1", "r1", "n4")) {
|
||||
t.Error("011: move that eliminates different-rack replica should not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_110_Composite(t *testing.T) {
|
||||
// 110 = 1 different-rack + 1 different-DC (3 replicas across 2 DCs and 2 racks)
|
||||
existing := []types.ReplicaLocation{
|
||||
loc("dc1", "r1", "n1"),
|
||||
loc("dc1", "r2", "n2"),
|
||||
loc("dc2", "r1", "n3"),
|
||||
}
|
||||
// Move n1 -> n4 in dc1/r3: good (dc1 still has r2+r3, dc2 has r1)
|
||||
if !IsGoodMove(rp(t, "110"), existing, "n1", loc("dc1", "r3", "n4")) {
|
||||
t.Error("110: move to new rack in same DC should be allowed")
|
||||
}
|
||||
// Move n3 -> n4 in dc1/r1: bad (would lose the different-DC replica)
|
||||
if IsGoodMove(rp(t, "110"), existing, "n3", loc("dc1", "r1", "n4")) {
|
||||
t.Error("110: move that eliminates different-DC replica should not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGoodMove_NilReplicaPlacement(t *testing.T) {
|
||||
if !IsGoodMove(nil, []types.ReplicaLocation{loc("dc1", "r1", "n1")}, "n1", loc("dc1", "r1", "n2")) {
|
||||
t.Error("nil replica placement should allow any move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBalanceScore_ReplicationAware(t *testing.T) {
|
||||
disk := func(dc, rack string) *topology.DiskInfo {
|
||||
return &topology.DiskInfo{
|
||||
DataCenter: dc,
|
||||
Rack: rack,
|
||||
DiskInfo: &master_pb.DiskInfo{MaxVolumeCount: 100, VolumeCount: 50},
|
||||
}
|
||||
}
|
||||
|
||||
// 001: same-rack replication — should prefer same rack and same DC
|
||||
rp001 := rp(t, "001")
|
||||
sameRack := calculateBalanceScore(disk("dc1", "r1"), "r1", "dc1", 0, rp001)
|
||||
diffRack := calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, rp001)
|
||||
diffDC := calculateBalanceScore(disk("dc2", "r2"), "r1", "dc1", 0, rp001)
|
||||
if sameRack <= diffRack {
|
||||
t.Errorf("001: same-rack score (%v) should exceed different-rack score (%v)", sameRack, diffRack)
|
||||
}
|
||||
if sameRack <= diffDC {
|
||||
t.Errorf("001: same-rack score (%v) should exceed different-DC score (%v)", sameRack, diffDC)
|
||||
}
|
||||
|
||||
// 010: different-rack replication — should prefer different rack, same DC
|
||||
rp010 := rp(t, "010")
|
||||
sameRack = calculateBalanceScore(disk("dc1", "r1"), "r1", "dc1", 0, rp010)
|
||||
diffRack = calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, rp010)
|
||||
if diffRack <= sameRack {
|
||||
t.Errorf("010: different-rack score (%v) should exceed same-rack score (%v)", diffRack, sameRack)
|
||||
}
|
||||
|
||||
// 100: different-DC replication — should prefer different DC
|
||||
rp100 := rp(t, "100")
|
||||
sameDC := calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, rp100)
|
||||
diffDCScore := calculateBalanceScore(disk("dc2", "r2"), "r1", "dc1", 0, rp100)
|
||||
if diffDCScore <= sameDC {
|
||||
t.Errorf("100: different-DC score (%v) should exceed same-DC score (%v)", diffDCScore, sameDC)
|
||||
}
|
||||
|
||||
// nil rp: should prefer cross-rack/DC (default behavior)
|
||||
sameRack = calculateBalanceScore(disk("dc1", "r1"), "r1", "dc1", 0, nil)
|
||||
diffRack = calculateBalanceScore(disk("dc1", "r2"), "r1", "dc1", 0, nil)
|
||||
if diffRack <= sameRack {
|
||||
t.Errorf("nil rp: different-rack score (%v) should exceed same-rack score (%v)", diffRack, sameRack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanBalanceDestination_ChoosesBestValidCompositeDestination(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack3"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
||||
}
|
||||
volumes := makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 1, withReplicas(11))
|
||||
replicas := []types.ReplicaLocation{
|
||||
loc("dc1", "rack1", "node-a"),
|
||||
loc("dc1", "rack1", "node-d"),
|
||||
loc("dc1", "rack2", "node-e"),
|
||||
}
|
||||
|
||||
plan, err := planBalanceDestination(buildTopology(servers, volumes), volumes[0], rp(t, "011"), replicas, map[string]int{
|
||||
"node-b": 0,
|
||||
"node-c": 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planBalanceDestination failed: %v", err)
|
||||
}
|
||||
if plan.TargetNode != "node-c" {
|
||||
t.Fatalf("expected valid same-rack destination node-c, got %s", plan.TargetNode)
|
||||
}
|
||||
if plan.TargetRack != "rack1" {
|
||||
t.Fatalf("expected rack1 destination, got %s", plan.TargetRack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBalanceTask_FallbackSelectsValidCompositeDestination(t *testing.T) {
|
||||
servers := []serverSpec{
|
||||
{id: "node-a", diskType: "hdd", diskID: 1, dc: "dc1", rack: "rack1"},
|
||||
{id: "node-b", diskType: "hdd", diskID: 2, dc: "dc1", rack: "rack3"},
|
||||
{id: "node-c", diskType: "hdd", diskID: 3, dc: "dc1", rack: "rack1"},
|
||||
}
|
||||
volumes := makeVolumesWith("node-a", "hdd", "dc1", "rack1", "c1", 1, 1, withReplicas(11))
|
||||
clusterInfo := &types.ClusterInfo{
|
||||
ActiveTopology: buildTopology(servers, volumes),
|
||||
VolumeReplicaMap: map[uint32][]types.ReplicaLocation{
|
||||
1: {
|
||||
loc("dc1", "rack1", "node-a"),
|
||||
loc("dc1", "rack1", "node-d"),
|
||||
loc("dc1", "rack2", "node-e"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
task, destination := createBalanceTask("hdd", volumes[0], clusterInfo, "node-b", map[string]int{
|
||||
"node-b": 0,
|
||||
"node-c": 0,
|
||||
}, 0)
|
||||
if task == nil {
|
||||
t.Fatal("expected a balance task")
|
||||
}
|
||||
if destination != "node-c" {
|
||||
t.Fatalf("expected fallback destination node-c, got %s", destination)
|
||||
}
|
||||
if len(task.TypedParams.Targets) != 1 {
|
||||
t.Fatalf("expected 1 target, got %d", len(task.TypedParams.Targets))
|
||||
}
|
||||
if got := task.TypedParams.Targets[0].Node; got != "node-c:8080" {
|
||||
t.Fatalf("expected target node-c:8080, got %s", got)
|
||||
}
|
||||
}
|
||||
37
weed/worker/tasks/balance/scheduling.go
Normal file
37
weed/worker/tasks/balance/scheduling.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Scheduling implements the scheduling logic for balance tasks
|
||||
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
|
||||
balanceConfig := config.(*Config)
|
||||
|
||||
// Count running balance tasks
|
||||
runningBalanceCount := 0
|
||||
for _, runningTask := range runningTasks {
|
||||
if runningTask.Type == types.TaskTypeBalance {
|
||||
runningBalanceCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrency limit
|
||||
if runningBalanceCount >= balanceConfig.MaxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we have available workers
|
||||
availableWorkerCount := 0
|
||||
for _, worker := range availableWorkers {
|
||||
for _, capability := range worker.Capabilities {
|
||||
if capability == types.TaskTypeBalance {
|
||||
availableWorkerCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableWorkerCount > 0
|
||||
}
|
||||
160
weed/worker/tasks/base/generic_components.go
Normal file
160
weed/worker/tasks/base/generic_components.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// GenericDetector implements TaskDetector using function-based logic
|
||||
type GenericDetector struct {
|
||||
taskDef *TaskDefinition
|
||||
}
|
||||
|
||||
// NewGenericDetector creates a detector from a task definition
|
||||
func NewGenericDetector(taskDef *TaskDefinition) *GenericDetector {
|
||||
return &GenericDetector{taskDef: taskDef}
|
||||
}
|
||||
|
||||
// GetTaskType returns the task type
|
||||
func (d *GenericDetector) GetTaskType() types.TaskType {
|
||||
return d.taskDef.Type
|
||||
}
|
||||
|
||||
// ScanForTasks scans using the task definition's detection function
|
||||
func (d *GenericDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo) ([]*types.TaskDetectionResult, error) {
|
||||
if d.taskDef.DetectionFunc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.taskDef.DetectionFunc(volumeMetrics, clusterInfo, d.taskDef.Config)
|
||||
}
|
||||
|
||||
// ScanInterval returns the scan interval from task definition
|
||||
func (d *GenericDetector) ScanInterval() time.Duration {
|
||||
if d.taskDef.ScanInterval > 0 {
|
||||
return d.taskDef.ScanInterval
|
||||
}
|
||||
return 30 * time.Minute // Default
|
||||
}
|
||||
|
||||
// IsEnabled returns whether this detector is enabled
|
||||
func (d *GenericDetector) IsEnabled() bool {
|
||||
return d.taskDef.Config.IsEnabled()
|
||||
}
|
||||
|
||||
// SetEnabled turns detection for this task type on or off.
|
||||
//
|
||||
// The admin maintenance policy is applied to detectors through an
|
||||
// interface{ SetEnabled(bool) } type assertion (see
|
||||
// MaintenanceIntegration.configureDetectorFromPolicy). Every registered task is
|
||||
// backed by this generic detector, so without this method that assertion failed
|
||||
// for every task and the policy never reached the flag that
|
||||
// ScanWithTaskDetectors actually gates on.
|
||||
func (d *GenericDetector) SetEnabled(enabled bool) {
|
||||
d.taskDef.Config.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// GenericScheduler implements TaskScheduler using function-based logic
|
||||
type GenericScheduler struct {
|
||||
taskDef *TaskDefinition
|
||||
}
|
||||
|
||||
// NewGenericScheduler creates a scheduler from a task definition
|
||||
func NewGenericScheduler(taskDef *TaskDefinition) *GenericScheduler {
|
||||
return &GenericScheduler{taskDef: taskDef}
|
||||
}
|
||||
|
||||
// GetTaskType returns the task type
|
||||
func (s *GenericScheduler) GetTaskType() types.TaskType {
|
||||
return s.taskDef.Type
|
||||
}
|
||||
|
||||
// CanScheduleNow determines if a task can be scheduled using the task definition's function
|
||||
func (s *GenericScheduler) CanScheduleNow(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData) bool {
|
||||
if s.taskDef.SchedulingFunc == nil {
|
||||
return s.defaultCanSchedule(task, runningTasks, availableWorkers)
|
||||
}
|
||||
return s.taskDef.SchedulingFunc(task, runningTasks, availableWorkers, s.taskDef.Config)
|
||||
}
|
||||
|
||||
// defaultCanSchedule provides default scheduling logic
|
||||
func (s *GenericScheduler) defaultCanSchedule(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData) bool {
|
||||
if !s.taskDef.Config.IsEnabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Count running tasks of this type
|
||||
runningCount := 0
|
||||
for _, runningTask := range runningTasks {
|
||||
if runningTask.Type == s.taskDef.Type {
|
||||
runningCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrency limit
|
||||
maxConcurrent := s.taskDef.MaxConcurrent
|
||||
if maxConcurrent <= 0 {
|
||||
maxConcurrent = 1 // Default
|
||||
}
|
||||
if runningCount >= maxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we have available workers
|
||||
for _, worker := range availableWorkers {
|
||||
if worker.CurrentLoad < worker.MaxConcurrent {
|
||||
for _, capability := range worker.Capabilities {
|
||||
if capability == s.taskDef.Type {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPriority returns the priority for this task
|
||||
func (s *GenericScheduler) GetPriority(task *types.TaskInput) types.TaskPriority {
|
||||
return task.Priority
|
||||
}
|
||||
|
||||
// GetMaxConcurrent returns max concurrent tasks
|
||||
func (s *GenericScheduler) GetMaxConcurrent() int {
|
||||
if s.taskDef.MaxConcurrent > 0 {
|
||||
return s.taskDef.MaxConcurrent
|
||||
}
|
||||
return 1 // Default
|
||||
}
|
||||
|
||||
// GetDefaultRepeatInterval returns the default repeat interval
|
||||
func (s *GenericScheduler) GetDefaultRepeatInterval() time.Duration {
|
||||
if s.taskDef.RepeatInterval > 0 {
|
||||
return s.taskDef.RepeatInterval
|
||||
}
|
||||
return 24 * time.Hour // Default
|
||||
}
|
||||
|
||||
// IsEnabled returns whether this scheduler is enabled
|
||||
func (s *GenericScheduler) IsEnabled() bool {
|
||||
return s.taskDef.Config.IsEnabled()
|
||||
}
|
||||
|
||||
// SetEnabled turns scheduling for this task type on or off. Detector and scheduler
|
||||
// share one TaskDefinition, so this is the same flag GenericDetector.SetEnabled sets;
|
||||
// both setters exist because the maintenance integration configures the two
|
||||
// independently. See GenericDetector.SetEnabled.
|
||||
func (s *GenericScheduler) SetEnabled(enabled bool) {
|
||||
s.taskDef.Config.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// SetMaxConcurrent applies the policy's concurrency limit for this task type. It is
|
||||
// the value GetMaxConcurrent returns, which the maintenance queue uses to decide
|
||||
// whether another task of this type may start. Non-positive limits are ignored
|
||||
// rather than turning into the implicit default of 1.
|
||||
func (s *GenericScheduler) SetMaxConcurrent(maxConcurrent int) {
|
||||
if maxConcurrent <= 0 {
|
||||
return
|
||||
}
|
||||
s.taskDef.MaxConcurrent = maxConcurrent
|
||||
}
|
||||
151
weed/worker/tasks/base/registration.go
Normal file
151
weed/worker/tasks/base/registration.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// GenericFactory creates task instances using a TaskDefinition
|
||||
type GenericFactory struct {
|
||||
*tasks.BaseTaskFactory
|
||||
taskDef *TaskDefinition
|
||||
}
|
||||
|
||||
// NewGenericFactory creates a generic task factory
|
||||
func NewGenericFactory(taskDef *TaskDefinition) *GenericFactory {
|
||||
return &GenericFactory{
|
||||
BaseTaskFactory: tasks.NewBaseTaskFactory(
|
||||
taskDef.Type,
|
||||
taskDef.Capabilities,
|
||||
taskDef.Description,
|
||||
),
|
||||
taskDef: taskDef,
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a task instance using the task definition
|
||||
func (f *GenericFactory) Create(params *worker_pb.TaskParams) (types.Task, error) {
|
||||
if f.taskDef.CreateTask == nil {
|
||||
return nil, fmt.Errorf("no task creation function defined for %s", f.taskDef.Type)
|
||||
}
|
||||
return f.taskDef.CreateTask(params)
|
||||
}
|
||||
|
||||
// Type returns the task type
|
||||
func (f *GenericFactory) Type() string {
|
||||
return string(f.taskDef.Type)
|
||||
}
|
||||
|
||||
// Description returns a description of what this task does
|
||||
func (f *GenericFactory) Description() string {
|
||||
return f.taskDef.Description
|
||||
}
|
||||
|
||||
// Capabilities returns the task capabilities
|
||||
func (f *GenericFactory) Capabilities() []string {
|
||||
return f.taskDef.Capabilities
|
||||
}
|
||||
|
||||
// GenericSchemaProvider provides config schema from TaskDefinition
|
||||
type GenericSchemaProvider struct {
|
||||
taskDef *TaskDefinition
|
||||
}
|
||||
|
||||
// GetConfigSchema returns the schema from task definition
|
||||
func (p *GenericSchemaProvider) GetConfigSchema() *tasks.TaskConfigSchema {
|
||||
return &tasks.TaskConfigSchema{
|
||||
TaskName: string(p.taskDef.Type),
|
||||
DisplayName: p.taskDef.DisplayName,
|
||||
Description: p.taskDef.Description,
|
||||
Icon: p.taskDef.Icon,
|
||||
Schema: config.Schema{
|
||||
Fields: p.taskDef.ConfigSpec.Fields,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenericUIProvider provides UI functionality from TaskDefinition
|
||||
type GenericUIProvider struct {
|
||||
taskDef *TaskDefinition
|
||||
}
|
||||
|
||||
// GetCurrentConfig returns current config as TaskConfig
|
||||
func (ui *GenericUIProvider) GetCurrentConfig() types.TaskConfig {
|
||||
return ui.taskDef.Config
|
||||
}
|
||||
|
||||
// ApplyTaskPolicy applies protobuf TaskPolicy configuration
|
||||
func (ui *GenericUIProvider) ApplyTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
return ui.taskDef.Config.FromTaskPolicy(policy)
|
||||
}
|
||||
|
||||
// ApplyTaskConfig applies TaskConfig interface configuration
|
||||
func (ui *GenericUIProvider) ApplyTaskConfig(config types.TaskConfig) error {
|
||||
taskPolicy := config.ToTaskPolicy()
|
||||
return ui.taskDef.Config.FromTaskPolicy(taskPolicy)
|
||||
}
|
||||
|
||||
// RegisterTask registers a complete task definition with all registries
|
||||
func RegisterTask(taskDef *TaskDefinition) {
|
||||
// Validate task definition
|
||||
if err := validateTaskDefinition(taskDef); err != nil {
|
||||
glog.Errorf("Invalid task definition for %s: %v", taskDef.Type, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create and register factory
|
||||
factory := NewGenericFactory(taskDef)
|
||||
tasks.AutoRegister(taskDef.Type, factory)
|
||||
|
||||
// Create and register detector/scheduler
|
||||
detector := NewGenericDetector(taskDef)
|
||||
scheduler := NewGenericScheduler(taskDef)
|
||||
|
||||
tasks.AutoRegisterTypes(func(registry *types.TaskRegistry) {
|
||||
registry.RegisterTask(detector, scheduler)
|
||||
})
|
||||
|
||||
// Create and register schema provider
|
||||
schemaProvider := &GenericSchemaProvider{taskDef: taskDef}
|
||||
tasks.RegisterTaskConfigSchema(string(taskDef.Type), schemaProvider)
|
||||
|
||||
// Create and register UI provider
|
||||
uiProvider := &GenericUIProvider{taskDef: taskDef}
|
||||
tasks.AutoRegisterUI(func(uiRegistry *types.UIRegistry) {
|
||||
baseUIProvider := tasks.NewBaseUIProvider(
|
||||
taskDef.Type,
|
||||
taskDef.DisplayName,
|
||||
taskDef.Description,
|
||||
taskDef.Icon,
|
||||
schemaProvider.GetConfigSchema,
|
||||
uiProvider.GetCurrentConfig,
|
||||
uiProvider.ApplyTaskPolicy,
|
||||
uiProvider.ApplyTaskConfig,
|
||||
)
|
||||
uiRegistry.RegisterUI(baseUIProvider)
|
||||
})
|
||||
|
||||
glog.V(1).Infof("Registered complete task definition: %s", taskDef.Type)
|
||||
}
|
||||
|
||||
// validateTaskDefinition ensures the task definition is complete
|
||||
func validateTaskDefinition(taskDef *TaskDefinition) error {
|
||||
if taskDef.Type == "" {
|
||||
return fmt.Errorf("task type is required")
|
||||
}
|
||||
if taskDef.Name == "" {
|
||||
return fmt.Errorf("task name is required")
|
||||
}
|
||||
if taskDef.Config == nil {
|
||||
return fmt.Errorf("task config is required")
|
||||
}
|
||||
if taskDef.CreateTask == nil {
|
||||
return fmt.Errorf("task creation function is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
105
weed/worker/tasks/base/task_definition.go
Normal file
105
weed/worker/tasks/base/task_definition.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// TaskDefinition encapsulates everything needed to define a complete task type
|
||||
type TaskDefinition struct {
|
||||
// Basic task information
|
||||
Type types.TaskType
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
Icon string
|
||||
Capabilities []string
|
||||
|
||||
// Task configuration
|
||||
Config TaskConfig
|
||||
ConfigSpec ConfigSpec
|
||||
|
||||
// Task creation
|
||||
CreateTask func(params *worker_pb.TaskParams) (types.Task, error)
|
||||
|
||||
// Detection logic
|
||||
DetectionFunc func(metrics []*types.VolumeHealthMetrics, info *types.ClusterInfo, config TaskConfig) ([]*types.TaskDetectionResult, error)
|
||||
ScanInterval time.Duration
|
||||
|
||||
// Scheduling logic
|
||||
SchedulingFunc func(task *types.TaskInput, running []*types.TaskInput, workers []*types.WorkerData, config TaskConfig) bool
|
||||
MaxConcurrent int
|
||||
RepeatInterval time.Duration
|
||||
}
|
||||
|
||||
// TaskConfig provides a configuration interface that supports type-safe defaults
|
||||
type TaskConfig interface {
|
||||
config.ConfigWithDefaults // Extends ConfigWithDefaults for type-safe schema operations
|
||||
IsEnabled() bool
|
||||
SetEnabled(bool)
|
||||
ToTaskPolicy() *worker_pb.TaskPolicy
|
||||
FromTaskPolicy(policy *worker_pb.TaskPolicy) error
|
||||
}
|
||||
|
||||
// ConfigSpec defines the configuration schema
|
||||
type ConfigSpec struct {
|
||||
Fields []*config.Field
|
||||
}
|
||||
|
||||
// BaseConfig provides common configuration fields with reflection-based serialization
|
||||
type BaseConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ScanIntervalSeconds int `json:"scan_interval_seconds"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the task is enabled
|
||||
func (c *BaseConfig) IsEnabled() bool {
|
||||
return c.Enabled
|
||||
}
|
||||
|
||||
// SetEnabled sets whether the task is enabled
|
||||
func (c *BaseConfig) SetEnabled(enabled bool) {
|
||||
c.Enabled = enabled
|
||||
}
|
||||
|
||||
// Validate validates the base configuration
|
||||
func (c *BaseConfig) Validate() error {
|
||||
// Common validation logic
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToMap converts config to map using reflection
|
||||
// ToTaskPolicy converts BaseConfig to protobuf (partial implementation)
|
||||
// Note: Concrete implementations should override this to include task-specific config
|
||||
func (c *BaseConfig) ToTaskPolicy() *worker_pb.TaskPolicy {
|
||||
return &worker_pb.TaskPolicy{
|
||||
Enabled: c.Enabled,
|
||||
MaxConcurrent: int32(c.MaxConcurrent),
|
||||
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
// TaskConfig field should be set by concrete implementations
|
||||
}
|
||||
}
|
||||
|
||||
// FromTaskPolicy loads BaseConfig from protobuf (partial implementation)
|
||||
// Note: Concrete implementations should override this to handle task-specific config
|
||||
func (c *BaseConfig) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy is nil")
|
||||
}
|
||||
c.Enabled = policy.Enabled
|
||||
c.MaxConcurrent = int(policy.MaxConcurrent)
|
||||
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplySchemaDefaults applies default values from schema using reflection
|
||||
func (c *BaseConfig) ApplySchemaDefaults(schema *config.Schema) error {
|
||||
// Use reflection-based approach for BaseConfig since it needs to handle embedded structs
|
||||
return schema.ApplyDefaultsToProtobuf(c)
|
||||
}
|
||||
247
weed/worker/tasks/base/typed_task.go
Normal file
247
weed/worker/tasks/base/typed_task.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// BaseTypedTask provides a base implementation for typed tasks with logger support
|
||||
type BaseTypedTask struct {
|
||||
taskType types.TaskType
|
||||
taskID string
|
||||
progress float64
|
||||
progressCallback func(float64, string)
|
||||
currentStage string
|
||||
cancelled bool
|
||||
mutex sync.RWMutex
|
||||
|
||||
// Logger functionality
|
||||
logger tasks.TaskLogger
|
||||
loggerConfig types.TaskLoggerConfig
|
||||
}
|
||||
|
||||
// NewBaseTypedTask creates a new base typed task
|
||||
func NewBaseTypedTask(taskType types.TaskType) *BaseTypedTask {
|
||||
return &BaseTypedTask{
|
||||
taskType: taskType,
|
||||
progress: 0.0,
|
||||
loggerConfig: types.TaskLoggerConfig{
|
||||
BaseLogDir: "/data/task_logs",
|
||||
MaxTasks: 100,
|
||||
MaxLogSizeMB: 10,
|
||||
EnableConsole: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetType returns the task type
|
||||
func (bt *BaseTypedTask) GetType() types.TaskType {
|
||||
return bt.taskType
|
||||
}
|
||||
|
||||
// IsCancellable returns whether the task can be cancelled
|
||||
func (bt *BaseTypedTask) IsCancellable() bool {
|
||||
return true // Most tasks can be cancelled
|
||||
}
|
||||
|
||||
// Cancel cancels the task
|
||||
func (bt *BaseTypedTask) Cancel() error {
|
||||
bt.mutex.Lock()
|
||||
defer bt.mutex.Unlock()
|
||||
bt.cancelled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsCancelled returns whether the task has been cancelled
|
||||
func (bt *BaseTypedTask) IsCancelled() bool {
|
||||
bt.mutex.RLock()
|
||||
defer bt.mutex.RUnlock()
|
||||
return bt.cancelled
|
||||
}
|
||||
|
||||
// GetProgress returns the current progress (0-100)
|
||||
func (bt *BaseTypedTask) GetProgress() float64 {
|
||||
bt.mutex.RLock()
|
||||
defer bt.mutex.RUnlock()
|
||||
return bt.progress
|
||||
}
|
||||
|
||||
// SetProgress sets the current progress and calls the callback if set
|
||||
func (bt *BaseTypedTask) SetProgress(progress float64) {
|
||||
bt.mutex.Lock()
|
||||
callback := bt.progressCallback
|
||||
stage := bt.currentStage
|
||||
bt.progress = progress
|
||||
bt.mutex.Unlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(progress, stage)
|
||||
}
|
||||
}
|
||||
|
||||
// SetProgressCallback sets the progress callback function
|
||||
func (bt *BaseTypedTask) SetProgressCallback(callback func(float64, string)) {
|
||||
bt.mutex.Lock()
|
||||
defer bt.mutex.Unlock()
|
||||
bt.progressCallback = callback
|
||||
}
|
||||
|
||||
// SetProgressWithStage sets the current progress with a stage description
|
||||
func (bt *BaseTypedTask) SetProgressWithStage(progress float64, stage string) {
|
||||
bt.mutex.Lock()
|
||||
callback := bt.progressCallback
|
||||
bt.progress = progress
|
||||
bt.currentStage = stage
|
||||
bt.mutex.Unlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(progress, stage)
|
||||
}
|
||||
}
|
||||
|
||||
// SetCurrentStage sets the current stage description
|
||||
func (bt *BaseTypedTask) SetCurrentStage(stage string) {
|
||||
bt.mutex.Lock()
|
||||
defer bt.mutex.Unlock()
|
||||
bt.currentStage = stage
|
||||
}
|
||||
|
||||
// GetCurrentStage returns the current stage description
|
||||
func (bt *BaseTypedTask) GetCurrentStage() string {
|
||||
bt.mutex.RLock()
|
||||
defer bt.mutex.RUnlock()
|
||||
return bt.currentStage
|
||||
}
|
||||
|
||||
// SetLoggerConfig sets the logger configuration for this task
|
||||
func (bt *BaseTypedTask) SetLoggerConfig(config types.TaskLoggerConfig) {
|
||||
bt.mutex.Lock()
|
||||
defer bt.mutex.Unlock()
|
||||
bt.loggerConfig = config
|
||||
}
|
||||
|
||||
// convertToTasksLoggerConfig converts types.TaskLoggerConfig to tasks.TaskLoggerConfig
|
||||
func convertToTasksLoggerConfig(config types.TaskLoggerConfig) tasks.TaskLoggerConfig {
|
||||
return tasks.TaskLoggerConfig{
|
||||
BaseLogDir: config.BaseLogDir,
|
||||
MaxTasks: config.MaxTasks,
|
||||
MaxLogSizeMB: config.MaxLogSizeMB,
|
||||
EnableConsole: config.EnableConsole,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeTaskLogger initializes the task logger with task details (LoggerProvider interface)
|
||||
func (bt *BaseTypedTask) InitializeTaskLogger(taskID string, workerID string, params types.TaskParams) error {
|
||||
bt.mutex.Lock()
|
||||
defer bt.mutex.Unlock()
|
||||
|
||||
bt.taskID = taskID
|
||||
|
||||
// Convert the logger config to the tasks package type
|
||||
tasksLoggerConfig := convertToTasksLoggerConfig(bt.loggerConfig)
|
||||
|
||||
logger, err := tasks.NewTaskLogger(taskID, bt.taskType, workerID, params, tasksLoggerConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize task logger: %w", err)
|
||||
}
|
||||
|
||||
bt.logger = logger
|
||||
if bt.logger != nil {
|
||||
bt.logger.Info("BaseTypedTask initialized for task %s (type: %s)", taskID, bt.taskType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTaskLogger returns the task logger (LoggerProvider interface)
|
||||
func (bt *BaseTypedTask) GetTaskLogger() types.TaskLogger {
|
||||
bt.mutex.RLock()
|
||||
defer bt.mutex.RUnlock()
|
||||
return bt.logger
|
||||
}
|
||||
|
||||
// LogInfo logs an info message
|
||||
func (bt *BaseTypedTask) LogInfo(message string, args ...interface{}) {
|
||||
bt.mutex.RLock()
|
||||
logger := bt.logger
|
||||
bt.mutex.RUnlock()
|
||||
|
||||
if logger != nil {
|
||||
logger.Info(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogWarning logs a warning message
|
||||
func (bt *BaseTypedTask) LogWarning(message string, args ...interface{}) {
|
||||
bt.mutex.RLock()
|
||||
logger := bt.logger
|
||||
bt.mutex.RUnlock()
|
||||
|
||||
if logger != nil {
|
||||
logger.Warning(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error message
|
||||
func (bt *BaseTypedTask) LogError(message string, args ...interface{}) {
|
||||
bt.mutex.RLock()
|
||||
logger := bt.logger
|
||||
bt.mutex.RUnlock()
|
||||
|
||||
if logger != nil {
|
||||
logger.Error(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogDebug logs a debug message
|
||||
func (bt *BaseTypedTask) LogDebug(message string, args ...interface{}) {
|
||||
bt.mutex.RLock()
|
||||
logger := bt.logger
|
||||
bt.mutex.RUnlock()
|
||||
|
||||
if logger != nil {
|
||||
logger.Debug(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogWithFields logs a message with structured fields
|
||||
func (bt *BaseTypedTask) LogWithFields(level string, message string, fields map[string]interface{}) {
|
||||
bt.mutex.RLock()
|
||||
logger := bt.logger
|
||||
bt.mutex.RUnlock()
|
||||
|
||||
if logger != nil {
|
||||
logger.LogWithFields(level, message, fields)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateTyped provides basic validation for typed parameters
|
||||
func (bt *BaseTypedTask) ValidateTyped(params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return errors.New("task parameters cannot be nil")
|
||||
}
|
||||
if params.VolumeId == 0 {
|
||||
return errors.New("volume_id is required")
|
||||
}
|
||||
if len(params.Sources) == 0 {
|
||||
return errors.New("at least one source is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateTimeTyped provides a default time estimation
|
||||
func (bt *BaseTypedTask) EstimateTimeTyped(params *worker_pb.TaskParams) time.Duration {
|
||||
// Default estimation - concrete tasks should override this
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// ExecuteTyped is a placeholder that concrete tasks must implement
|
||||
func (bt *BaseTypedTask) ExecuteTyped(params *worker_pb.TaskParams) error {
|
||||
panic("ExecuteTyped must be implemented by concrete task types")
|
||||
}
|
||||
36
weed/worker/tasks/base/volume_utils.go
Normal file
36
weed/worker/tasks/base/volume_utils.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
)
|
||||
|
||||
// FindVolumeDisk finds the disk ID where a specific volume is located on a given server.
|
||||
// Returns the disk ID and a boolean indicating whether the volume was found.
|
||||
// Uses O(1) indexed lookup for optimal performance on large clusters.
|
||||
//
|
||||
// This is a shared utility function used by multiple task detection algorithms
|
||||
// (balance, vacuum, etc.) to locate volumes efficiently.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// // In balance task: find source disk for a volume that needs to be moved
|
||||
// sourceDisk, found := base.FindVolumeDisk(topology, volumeID, collection, sourceServer)
|
||||
//
|
||||
// // In vacuum task: find disk containing volume that needs cleanup
|
||||
// diskID, exists := base.FindVolumeDisk(topology, volumeID, collection, serverID)
|
||||
func FindVolumeDisk(activeTopology *topology.ActiveTopology, volumeID uint32, collection string, serverID string) (uint32, bool) {
|
||||
if activeTopology == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Use the new O(1) indexed lookup for better performance
|
||||
locations := activeTopology.GetVolumeLocations(volumeID, collection)
|
||||
for _, loc := range locations {
|
||||
if loc.ServerID == serverID {
|
||||
return loc.DiskID, true
|
||||
}
|
||||
}
|
||||
|
||||
// Volume not found on this server
|
||||
return 0, false
|
||||
}
|
||||
67
weed/worker/tasks/config_update_registry.go
Normal file
67
weed/worker/tasks/config_update_registry.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// ConfigUpdateFunc is a function type for updating task configurations
|
||||
type ConfigUpdateFunc func(configPersistence interface{}) error
|
||||
|
||||
// ConfigUpdateRegistry manages config update functions for all task types
|
||||
type ConfigUpdateRegistry struct {
|
||||
updaters map[types.TaskType]ConfigUpdateFunc
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalConfigUpdateRegistry *ConfigUpdateRegistry
|
||||
configUpdateRegistryOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalConfigUpdateRegistry returns the global config update registry (singleton)
|
||||
func GetGlobalConfigUpdateRegistry() *ConfigUpdateRegistry {
|
||||
configUpdateRegistryOnce.Do(func() {
|
||||
globalConfigUpdateRegistry = &ConfigUpdateRegistry{
|
||||
updaters: make(map[types.TaskType]ConfigUpdateFunc),
|
||||
}
|
||||
glog.V(1).Infof("Created global config update registry")
|
||||
})
|
||||
return globalConfigUpdateRegistry
|
||||
}
|
||||
|
||||
// RegisterConfigUpdater registers a config update function for a task type
|
||||
func (r *ConfigUpdateRegistry) RegisterConfigUpdater(taskType types.TaskType, updateFunc ConfigUpdateFunc) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
r.updaters[taskType] = updateFunc
|
||||
glog.V(1).Infof("Registered config updater for task type: %s", taskType)
|
||||
}
|
||||
|
||||
// UpdateAllConfigs updates configurations for all registered task types
|
||||
func (r *ConfigUpdateRegistry) UpdateAllConfigs(configPersistence interface{}) {
|
||||
r.mutex.RLock()
|
||||
updaters := make(map[types.TaskType]ConfigUpdateFunc)
|
||||
for k, v := range r.updaters {
|
||||
updaters[k] = v
|
||||
}
|
||||
r.mutex.RUnlock()
|
||||
|
||||
for taskType, updateFunc := range updaters {
|
||||
if err := updateFunc(configPersistence); err != nil {
|
||||
glog.Warningf("Failed to load %s configuration from persistence: %v", taskType, err)
|
||||
} else {
|
||||
glog.V(1).Infof("Loaded %s configuration from persistence", taskType)
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(1).Infof("All task configurations loaded from persistence")
|
||||
}
|
||||
|
||||
// AutoRegisterConfigUpdater is a convenience function for registering config updaters
|
||||
func AutoRegisterConfigUpdater(taskType types.TaskType, updateFunc ConfigUpdateFunc) {
|
||||
registry := GetGlobalConfigUpdateRegistry()
|
||||
registry.RegisterConfigUpdater(taskType, updateFunc)
|
||||
}
|
||||
267
weed/worker/tasks/ec_balance/config.go
Normal file
267
weed/worker/tasks/ec_balance/config.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
)
|
||||
|
||||
// Config extends BaseConfig with EC balance specific settings
|
||||
type Config struct {
|
||||
base.BaseConfig
|
||||
ImbalanceThreshold float64 `json:"imbalance_threshold"`
|
||||
MinServerCount int `json:"min_server_count"`
|
||||
CollectionFilter string `json:"collection_filter"`
|
||||
DiskType string `json:"disk_type"`
|
||||
PreferredTags []string `json:"preferred_tags"`
|
||||
ReplicaPlacement string `json:"replica_placement"` // e.g. "020"; empty falls back to the master default replication (even spread only when that default is empty or zero)
|
||||
IoBytePerSecond int64 `json:"io_byte_per_second"` // limit each shard copy's rate; 0 falls back to the volume server's maintenance rate
|
||||
DataCenterFilter string `json:"-"` // per-detection-run, not persisted
|
||||
}
|
||||
|
||||
// NewDefaultConfig creates a new default EC balance configuration
|
||||
func NewDefaultConfig() *Config {
|
||||
return &Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: true,
|
||||
ScanIntervalSeconds: 60 * 60, // 1 hour
|
||||
MaxConcurrent: 1,
|
||||
},
|
||||
ImbalanceThreshold: 0.2, // 20%
|
||||
MinServerCount: 3,
|
||||
CollectionFilter: "",
|
||||
DiskType: "",
|
||||
PreferredTags: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec returns the configuration schema for EC balance tasks
|
||||
func GetConfigSpec() base.ConfigSpec {
|
||||
return base.ConfigSpec{
|
||||
Fields: []*config.Field{
|
||||
{
|
||||
Name: "enabled",
|
||||
JSONName: "enabled",
|
||||
Type: config.FieldTypeBool,
|
||||
DefaultValue: true,
|
||||
Required: false,
|
||||
DisplayName: "Enable EC Shard Balance Tasks",
|
||||
Description: "Whether EC shard balance tasks should be automatically created",
|
||||
HelpText: "Toggle this to enable or disable automatic EC shard balancing",
|
||||
InputType: "checkbox",
|
||||
CSSClasses: "form-check-input",
|
||||
},
|
||||
{
|
||||
Name: "scan_interval_seconds",
|
||||
JSONName: "scan_interval_seconds",
|
||||
Type: config.FieldTypeInterval,
|
||||
DefaultValue: 60 * 60,
|
||||
MinValue: 10 * 60,
|
||||
MaxValue: 24 * 60 * 60,
|
||||
Required: true,
|
||||
DisplayName: "Scan Interval",
|
||||
Description: "How often to scan for EC shard imbalances",
|
||||
HelpText: "The system will check for EC shard distribution imbalances at this interval",
|
||||
Placeholder: "1",
|
||||
Unit: config.UnitHours,
|
||||
InputType: "interval",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "max_concurrent",
|
||||
JSONName: "max_concurrent",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 1,
|
||||
MinValue: 1,
|
||||
MaxValue: 5,
|
||||
Required: true,
|
||||
DisplayName: "Max Concurrent Tasks",
|
||||
Description: "Maximum number of EC shard balance tasks that can run simultaneously",
|
||||
HelpText: "Limits the number of EC shard balancing operations running at the same time",
|
||||
Placeholder: "1 (default)",
|
||||
Unit: config.UnitCount,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "imbalance_threshold",
|
||||
JSONName: "imbalance_threshold",
|
||||
Type: config.FieldTypeFloat,
|
||||
DefaultValue: 0.2,
|
||||
MinValue: 0.05,
|
||||
MaxValue: 0.5,
|
||||
Required: true,
|
||||
DisplayName: "Imbalance Threshold",
|
||||
Description: "Minimum shard count imbalance ratio to trigger balancing",
|
||||
HelpText: "EC shard distribution imbalances above this threshold will trigger rebalancing",
|
||||
Placeholder: "0.20 (20%)",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "min_server_count",
|
||||
JSONName: "min_server_count",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 3,
|
||||
MinValue: 2,
|
||||
MaxValue: 100,
|
||||
Required: true,
|
||||
DisplayName: "Minimum Server Count",
|
||||
Description: "Minimum number of servers required for EC shard balancing",
|
||||
HelpText: "EC shard balancing will only occur if there are at least this many servers",
|
||||
Placeholder: "3 (default)",
|
||||
Unit: config.UnitCount,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "collection_filter",
|
||||
JSONName: "collection_filter",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Collection Filter",
|
||||
Description: "Only balance EC shards from specific collections (comma-separated names, wildcards, or regex patterns)",
|
||||
HelpText: "Leave empty to balance all collections, or list the collections to balance",
|
||||
Placeholder: "pictures,videos",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "disk_type",
|
||||
JSONName: "disk_type",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Disk Type",
|
||||
Description: "Only balance EC shards on this disk type",
|
||||
HelpText: "Leave empty for all disk types, or specify hdd or ssd",
|
||||
Placeholder: "hdd",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "preferred_tags",
|
||||
JSONName: "preferred_tags",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Preferred Disk Tags",
|
||||
Description: "Comma-separated disk tags to prioritize for shard placement",
|
||||
HelpText: "EC shards will be placed on disks with these tags first, ordered by preference",
|
||||
Placeholder: "fast,ssd",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "replica_placement",
|
||||
JSONName: "replica_placement",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Replica Placement",
|
||||
Description: "EC shard replica placement constraint (e.g. 020)",
|
||||
HelpText: "Leave empty to use the master default replication (even spread only when that default is empty or zero). When set, limits shards per rack/node per the placement digits (dc/rack/node)",
|
||||
Placeholder: "020",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "io_byte_per_second",
|
||||
JSONName: "io_byte_per_second",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 0,
|
||||
MinValue: 0,
|
||||
Required: false,
|
||||
DisplayName: "Shard Copy IO Limit (bytes/sec)",
|
||||
Description: "Limit each EC shard copy's rate",
|
||||
HelpText: "0 falls back to each volume server's own maintenance rate (-maintenanceBytePerSecond)",
|
||||
Placeholder: "0 (server maintenance rate)",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
|
||||
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
|
||||
preferredTagsCopy := append([]string(nil), c.PreferredTags...)
|
||||
return &worker_pb.TaskPolicy{
|
||||
Enabled: c.Enabled,
|
||||
MaxConcurrent: int32(c.MaxConcurrent),
|
||||
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
TaskConfig: &worker_pb.TaskPolicy_EcBalanceConfig{
|
||||
EcBalanceConfig: &worker_pb.EcBalanceTaskConfig{
|
||||
ImbalanceThreshold: c.ImbalanceThreshold,
|
||||
MinServerCount: int32(c.MinServerCount),
|
||||
CollectionFilter: c.CollectionFilter,
|
||||
DiskType: c.DiskType,
|
||||
PreferredTags: preferredTagsCopy,
|
||||
ReplicaPlacement: c.ReplicaPlacement,
|
||||
IoBytePerSecond: c.IoBytePerSecond,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
|
||||
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy is nil")
|
||||
}
|
||||
|
||||
c.Enabled = policy.Enabled
|
||||
c.MaxConcurrent = int(policy.MaxConcurrent)
|
||||
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds)
|
||||
|
||||
if ecbConfig := policy.GetEcBalanceConfig(); ecbConfig != nil {
|
||||
c.ImbalanceThreshold = ecbConfig.ImbalanceThreshold
|
||||
c.MinServerCount = int(ecbConfig.MinServerCount)
|
||||
c.CollectionFilter = ecbConfig.CollectionFilter
|
||||
c.DiskType = ecbConfig.DiskType
|
||||
c.PreferredTags = append([]string(nil), ecbConfig.PreferredTags...)
|
||||
c.ReplicaPlacement = ecbConfig.ReplicaPlacement
|
||||
c.IoBytePerSecond = ecbConfig.IoBytePerSecond
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigFromPersistence loads configuration from the persistence layer if available
|
||||
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
|
||||
cfg := NewDefaultConfig()
|
||||
|
||||
if persistence, ok := configPersistence.(interface {
|
||||
LoadEcBalanceTaskPolicy() (*worker_pb.TaskPolicy, error)
|
||||
}); ok {
|
||||
policy, err := persistence.LoadEcBalanceTaskPolicy()
|
||||
switch {
|
||||
case err != nil:
|
||||
glog.Warningf("Could not read the persisted EC balance configuration, falling back to defaults: %v", err)
|
||||
case policy == nil:
|
||||
glog.V(1).Infof("No EC balance configuration persisted yet, using defaults")
|
||||
default:
|
||||
if err := cfg.FromTaskPolicy(policy); err != nil {
|
||||
glog.Warningf("Could not apply the persisted EC balance configuration, falling back to defaults: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("Loaded EC balance configuration from persistence")
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
} else if configPersistence != nil {
|
||||
// A store was handed in but does not expose the accessor, so the persisted
|
||||
// settings are silently ignored - always a wiring bug, never a normal state.
|
||||
glog.Warningf("%T cannot provide the persisted EC balance configuration: it has no LoadEcBalanceTaskPolicy() method, "+
|
||||
"so the compiled-in defaults are used and any saved EC balance settings are ignored", configPersistence)
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Using default EC balance configuration")
|
||||
return cfg
|
||||
}
|
||||
128
weed/worker/tasks/ec_balance/dedup_safety_test.go
Normal file
128
weed/worker/tasks/ec_balance/dedup_safety_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
)
|
||||
|
||||
// The dedup phase deletes a shard it believes is duplicated elsewhere. It picks
|
||||
// its victims from the master's topology, and the topology can name a location
|
||||
// that holds nothing: a volume server answering "not found ec volume id N" for
|
||||
// a shard the master lists is the observable form of that. Deleting on the
|
||||
// strength of a phantom peer removes the only real copy.
|
||||
//
|
||||
// The move phase already refuses to work on trust — it verifies the shard
|
||||
// registered on the destination before removing the source. These tests hold
|
||||
// dedup to the same standard, because it is the more dangerous of the two: it
|
||||
// deletes without copying anything first.
|
||||
|
||||
const (
|
||||
dedupTestVolumeID = uint32(42561)
|
||||
dedupTestCollection = "pm-itatiaiucu-01"
|
||||
)
|
||||
|
||||
func dedupParams(sourceNode, keepNode string, shardID uint32) *worker_pb.TaskParams {
|
||||
// Dedup is signalled by source and target being the same node and disk.
|
||||
loc := &worker_pb.TaskSource{Node: sourceNode, DiskId: 0, ShardIds: []uint32{shardID}}
|
||||
return &worker_pb.TaskParams{
|
||||
VolumeId: dedupTestVolumeID,
|
||||
Collection: dedupTestCollection,
|
||||
Sources: []*worker_pb.TaskSource{loc},
|
||||
Targets: []*worker_pb.TaskTarget{{Node: sourceNode, DiskId: 0, ShardIds: []uint32{shardID}}},
|
||||
// The dedup branch is only reachable when EC params are present; without
|
||||
// them the job silently takes the copy-and-move path instead.
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{DedupKeepNode: keepNode},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDedupTask() *ECBalanceTask {
|
||||
return NewECBalanceTask("dedup-test", dedupTestVolumeID, dedupTestCollection,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
}
|
||||
|
||||
// The failure that cost this cluster shards: the topology claims shard 0 lives
|
||||
// on two nodes, only one of them actually has it, and dedup is pointed at the
|
||||
// one that does. Nothing else in the cluster holds shard 0, so deleting it is
|
||||
// unrecoverable — and no copy was made first, because dedup never makes one.
|
||||
func TestDedupRefusesToDeleteTheOnlyRealCopy(t *testing.T) {
|
||||
holder := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
|
||||
// The peer the topology believes also has shard 0. Its disks are empty,
|
||||
// exactly like a server answering "not found ec volume id".
|
||||
phantom := startFakeEcVolumeServer(t, dedupTestVolumeID)
|
||||
|
||||
if !holder.has(dedupTestVolumeID, 0) {
|
||||
t.Fatal("precondition: holder should start with shard 0")
|
||||
}
|
||||
if phantom.has(dedupTestVolumeID, 0) {
|
||||
t.Fatal("precondition: phantom should hold nothing")
|
||||
}
|
||||
|
||||
err := newDedupTask().Execute(context.Background(), dedupParams(holder.address(), phantom.address(), 0))
|
||||
|
||||
if holder.has(dedupTestVolumeID, 0) {
|
||||
return // refused, or verified and backed off — either is safe
|
||||
}
|
||||
t.Fatalf("dedup deleted the only copy of shard %d.0 (deleted=%v, err=%v); "+
|
||||
"the surviving copy was never confirmed to exist",
|
||||
dedupTestVolumeID, holder.deletedShards(), err)
|
||||
}
|
||||
|
||||
// A shard genuinely present on two servers is safe to thin out: one copy is
|
||||
// redundant and removing it loses nothing. Dedup has to keep working here, or
|
||||
// the guard above has simply disabled the feature.
|
||||
func TestDedupRemovesAGenuineDuplicate(t *testing.T) {
|
||||
victim := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
|
||||
survivor := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
|
||||
|
||||
if err := newDedupTask().Execute(context.Background(), dedupParams(victim.address(), survivor.address(), 0)); err != nil {
|
||||
t.Fatalf("dedup of a real duplicate should succeed, got %v", err)
|
||||
}
|
||||
if victim.has(dedupTestVolumeID, 0) {
|
||||
t.Error("dedup left the duplicate in place; capacity is not reclaimed")
|
||||
}
|
||||
if !survivor.has(dedupTestVolumeID, 0) {
|
||||
t.Error("dedup removed the surviving copy instead of the duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable peer is not evidence of a surviving copy. Treating a failed
|
||||
// query as "the other side has it" is how a network blip becomes data loss.
|
||||
func TestDedupRefusesWhenThePeerCannotBeQueried(t *testing.T) {
|
||||
holder := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
|
||||
unreachable := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
|
||||
unreachable.stop() // peer down; its inventory is unknown, not empty
|
||||
|
||||
err := newDedupTask().Execute(context.Background(), dedupParams(holder.address(), unreachable.address(), 0))
|
||||
|
||||
if holder.has(dedupTestVolumeID, 0) {
|
||||
return // kept the shard while the peer's state is unknown
|
||||
}
|
||||
t.Fatalf("dedup deleted shard %d.0 while the peer was unreachable (deleted=%v, err=%v)",
|
||||
dedupTestVolumeID, holder.deletedShards(), err)
|
||||
}
|
||||
|
||||
// Volume ids are allocated cluster-wide, but the inventory RPC is keyed by
|
||||
// volume id alone, so a server holding the same number for a different
|
||||
// collection answers "yes, I have that shard" to a question about this one.
|
||||
// Accepting that would delete the last real copy on the strength of an
|
||||
// unrelated volume.
|
||||
func TestDedupRefusesWhenTheKeepNodeHoldsAnotherCollection(t *testing.T) {
|
||||
holder := startFakeEcVolumeServer(t, dedupTestVolumeID, 0)
|
||||
// Same volume id, different collection: a plausible-looking but wrong match.
|
||||
impostor := startFakeEcVolumeServerInCollection(t, "morro-agudo-01", dedupTestVolumeID, 0)
|
||||
|
||||
err := newDedupTask().Execute(context.Background(), dedupParams(holder.address(), impostor.address(), 0))
|
||||
|
||||
if holder.has(dedupTestVolumeID, 0) {
|
||||
return // refused: the keep node's shard belongs to another collection
|
||||
}
|
||||
t.Fatalf("dedup deleted shard %d.0 after matching another collection's volume (deleted=%v, err=%v)",
|
||||
dedupTestVolumeID, holder.deletedShards(), err)
|
||||
}
|
||||
331
weed/worker/tasks/ec_balance/detection.go
Normal file
331
weed/worker/tasks/ec_balance/detection.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
storagetypes "github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/topology/balancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Detection builds an EC balance topology snapshot from the cluster's active
|
||||
// topology, runs the shared ecbalancer planner, and converts the planned moves
|
||||
// into worker task proposals. The balancing policy lives in
|
||||
// weed/storage/erasure_coding/ecbalancer, shared with the shell ec.balance
|
||||
// command so the two cannot drift.
|
||||
func Detection(
|
||||
ctx context.Context,
|
||||
metrics []*types.VolumeHealthMetrics,
|
||||
clusterInfo *types.ClusterInfo,
|
||||
config base.TaskConfig,
|
||||
maxResults int,
|
||||
) ([]*types.TaskDetectionResult, bool, error) {
|
||||
if !config.IsEnabled() {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
ecConfig := config.(*Config)
|
||||
if maxResults < 0 {
|
||||
maxResults = 0
|
||||
}
|
||||
|
||||
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
|
||||
return nil, false, fmt.Errorf("active topology not available for EC balance detection")
|
||||
}
|
||||
topoInfo := clusterInfo.ActiveTopology.GetTopologyInfo()
|
||||
if topoInfo == nil {
|
||||
return nil, false, fmt.Errorf("topology info not available")
|
||||
}
|
||||
|
||||
allowedCollections, err := wildcard.CompileCollectionMatcher(ecConfig.CollectionFilter)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
topo, nodeCount, volumeRatio := buildBalancerTopology(topoInfo, ecConfig, allowedCollections)
|
||||
if nodeCount < ecConfig.MinServerCount {
|
||||
glog.V(1).Infof("EC balance: only %d servers, need at least %d", nodeCount, ecConfig.MinServerCount)
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
replicaPlacement := resolveReplicaPlacement(ecConfig, clusterInfo)
|
||||
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical disk type for placement/execution: "hdd" -> "" (HardDriveType),
|
||||
// matching the topology's disk keys and the volume server's move RPCs.
|
||||
normalizedDiskType := storagetypes.ToDiskType(ecConfig.DiskType).String()
|
||||
|
||||
moves := ecbalancer.Plan(topo, ecbalancer.Options{
|
||||
DiskType: normalizedDiskType,
|
||||
ImbalanceThreshold: ecConfig.ImbalanceThreshold,
|
||||
ReplicaPlacement: replicaPlacement,
|
||||
Ratio: func(collection string) (int, int) {
|
||||
return resolveECRatio(clusterInfo, collection)
|
||||
},
|
||||
// Prefer each volume's own heartbeat-reported ratio over the collection
|
||||
// default so a mixed-ratio collection is spread per volume; 0 defers to
|
||||
// resolveECRatio (and is the always-0 OSS case).
|
||||
VolumeRatio: volumeRatio,
|
||||
// Move incrementally across detection cycles rather than draining a rack
|
||||
// in one batch; the scheduler re-evaluates each cycle.
|
||||
GlobalMaxMovesPerRack: 10,
|
||||
// Balance heterogeneous-capacity racks by fractional fullness.
|
||||
GlobalUtilizationBased: true,
|
||||
})
|
||||
if len(moves) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
hasMore := false
|
||||
if maxResults > 0 && len(moves) > maxResults {
|
||||
moves = moves[:maxResults]
|
||||
hasMore = true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
results := make([]*types.TaskDetectionResult, 0, len(moves))
|
||||
for i, m := range moves {
|
||||
taskID := fmt.Sprintf("ec_balance_%d_%d_%s_%s_%d_%d",
|
||||
m.VolumeID, m.ShardID, m.SourceNode, m.TargetNode, now.UnixNano(), i)
|
||||
results = append(results, &types.TaskDetectionResult{
|
||||
TaskID: taskID,
|
||||
TaskType: types.TaskTypeECBalance,
|
||||
VolumeID: m.VolumeID,
|
||||
Server: m.SourceNode,
|
||||
Collection: m.Collection,
|
||||
Priority: movePhasePriority(m.Phase),
|
||||
Reason: fmt.Sprintf("EC shard %d.%d %s: %s → %s",
|
||||
m.VolumeID, m.ShardID, m.Phase, m.SourceNode, m.TargetNode),
|
||||
ScheduleAt: now,
|
||||
TypedParams: &worker_pb.TaskParams{
|
||||
TaskId: taskID,
|
||||
VolumeId: m.VolumeID,
|
||||
Collection: m.Collection,
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: m.SourceNode,
|
||||
DiskId: m.SourceDisk,
|
||||
Rack: m.SourceRack,
|
||||
ShardIds: []uint32{uint32(m.ShardID)},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: m.TargetNode,
|
||||
DiskId: m.TargetDisk,
|
||||
Rack: m.TargetRack,
|
||||
ShardIds: []uint32{uint32(m.ShardID)},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
DiskType: normalizedDiskType,
|
||||
DedupKeepNode: m.KeepNode,
|
||||
TimeoutSeconds: 600,
|
||||
IoBytePerSecond: ecConfig.IoBytePerSecond,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
glog.V(1).Infof("EC balance detection: %d moves proposed", len(results))
|
||||
return results, hasMore, nil
|
||||
}
|
||||
|
||||
// buildBalancerTopology builds an ecbalancer.Topology from the master topology,
|
||||
// applying the data-center, disk-type, and collection filters. Rack keys are
|
||||
// dc:rack composites to avoid cross-DC name collisions. Per-disk free capacity
|
||||
// is split evenly from the node total because the wire collapses same-type disks.
|
||||
// Returns the topology, the number of eligible nodes (for MinServerCount), and a
|
||||
// per-volume ratio lookup built from each shard's heartbeat (0,0 when unreported,
|
||||
// e.g. always in OSS) which Plan prefers over the collection ratio for mixed-ratio
|
||||
// clusters.
|
||||
func buildBalancerTopology(topoInfo *master_pb.TopologyInfo, config *Config, allowedCollections *wildcard.CollectionMatcher) (*ecbalancer.Topology, int, func(collection string, vid uint32) (int, int)) {
|
||||
topo := ecbalancer.NewTopology()
|
||||
|
||||
type volRatioKey struct {
|
||||
collection string
|
||||
vid uint32
|
||||
}
|
||||
volRatios := make(map[volRatioKey][2]int)
|
||||
|
||||
// Normalize the disk-type filter: "hdd" (and the default "") map to the
|
||||
// HardDriveType, which the topology reports under the empty-string key. Keep a
|
||||
// separate "filter requested" flag so a configured "hdd" still filters to HDD
|
||||
// disks instead of being mistaken for "all disk types".
|
||||
filterByDiskType := config.DiskType != ""
|
||||
wantDiskType := storagetypes.ToDiskType(config.DiskType).String()
|
||||
|
||||
nodeCount := 0
|
||||
for _, dc := range topoInfo.DataCenterInfos {
|
||||
if config.DataCenterFilter != "" {
|
||||
matchers := wildcard.CompileWildcardMatchers(config.DataCenterFilter)
|
||||
if !wildcard.MatchesAnyWildcard(matchers, dc.Id) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, rack := range dc.RackInfos {
|
||||
rackKey := dc.Id + ":" + rack.Id
|
||||
|
||||
for _, dn := range rack.DataNodeInfos {
|
||||
freeSlots := 0
|
||||
diskTypeOf := make(map[uint32]string) // physical disk_id -> disk type
|
||||
diskShardCount := make(map[uint32]int)
|
||||
fullDiskTypes := make(map[string]bool) // physically near-full, ineligible as a target
|
||||
hasMatchingDisk := false
|
||||
|
||||
for diskType, diskInfo := range dn.DiskInfos {
|
||||
if filterByDiskType && diskType != wantDiskType {
|
||||
continue
|
||||
}
|
||||
hasMatchingDisk = true
|
||||
|
||||
// Don't place EC shards on a physically near-full disk, even if slot
|
||||
// math says it has room (an over-set maxVolumeCount hides real
|
||||
// fullness). Statfs free bytes already include EC shard files.
|
||||
if balancer.DiskTooFullAfter(diskInfo.DiskTotalBytes, diskInfo.DiskFreeBytes, 0, balancer.DefaultMaxDiskUsagePercent) {
|
||||
fullDiskTypes[diskType] = true
|
||||
}
|
||||
|
||||
fs := int(diskInfo.MaxVolumeCount-diskInfo.VolumeCount)*erasure_coding.DataShardsCount - countEcShards(diskInfo.EcShardInfos)
|
||||
if fs > 0 && !fullDiskTypes[diskType] {
|
||||
freeSlots += fs
|
||||
}
|
||||
// Discover physical disks from regular volumes too, so an
|
||||
// EC-empty disk is still a candidate destination.
|
||||
for _, vi := range diskInfo.VolumeInfos {
|
||||
if _, ok := diskTypeOf[vi.DiskId]; !ok {
|
||||
diskTypeOf[vi.DiskId] = diskType
|
||||
}
|
||||
}
|
||||
for _, eci := range diskInfo.EcShardInfos {
|
||||
if _, ok := diskTypeOf[eci.DiskId]; !ok {
|
||||
diskTypeOf[eci.DiskId] = diskType
|
||||
}
|
||||
// Disk occupancy counts ALL volumes' shards (capacity model),
|
||||
// independent of the collection filter below.
|
||||
diskShardCount[eci.DiskId] += erasure_coding.GetShardCount(eci)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasMatchingDisk {
|
||||
continue
|
||||
}
|
||||
|
||||
node := topo.AddNode(dn.Id, dc.Id, rackKey, freeSlots)
|
||||
// Group servers sharing a host so a volume's shards spread across
|
||||
// machines, not just nodes (servers on one host are one fault domain).
|
||||
node.SetHost(pb.NewServerAddressFromDataNode(dn).ToHost())
|
||||
|
||||
// Spread the node's free slots only over its non-full disks, so a
|
||||
// physically full disk type doesn't dilute the share the usable disks
|
||||
// advertise (its own disks get 0 below). The total is preserved.
|
||||
nonFullDiskCount := 0
|
||||
for _, dt := range diskTypeOf {
|
||||
if !fullDiskTypes[dt] {
|
||||
nonFullDiskCount++
|
||||
}
|
||||
}
|
||||
perDiskFree := 0
|
||||
if nonFullDiskCount > 0 && freeSlots > 0 {
|
||||
perDiskFree = freeSlots / nonFullDiskCount
|
||||
}
|
||||
for diskID, diskType := range diskTypeOf {
|
||||
diskFree := perDiskFree
|
||||
if fullDiskTypes[diskType] {
|
||||
diskFree = 0
|
||||
}
|
||||
node.AddDisk(diskID, diskType, diskFree, diskShardCount[diskID])
|
||||
}
|
||||
|
||||
// Add shards only for volumes whose collection passes the filter;
|
||||
// those are the volumes the planner will balance.
|
||||
for diskType, diskInfo := range dn.DiskInfos {
|
||||
if filterByDiskType && diskType != wantDiskType {
|
||||
continue
|
||||
}
|
||||
for _, eci := range diskInfo.EcShardInfos {
|
||||
if !allowedCollections.Matches(eci.Collection) {
|
||||
continue
|
||||
}
|
||||
node.AddShards(eci.Id, eci.Collection, eci.DiskId, erasure_coding.ShardBits(eci.EcIndexBits))
|
||||
if d, p := ecbalancer.VolumeShardRatio(eci); d > 0 || p > 0 {
|
||||
volRatios[volRatioKey{eci.Collection, eci.Id}] = [2]int{d, p}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volumeRatio := func(collection string, vid uint32) (int, int) {
|
||||
r := volRatios[volRatioKey{collection, vid}]
|
||||
return r[0], r[1]
|
||||
}
|
||||
return topo, nodeCount, volumeRatio
|
||||
}
|
||||
|
||||
// resolveECRatio returns the (dataShards, parityShards) for a collection from the
|
||||
// admin EC config snapshot when present, else the local default. This keeps the
|
||||
// enterprise-only custom-ratio plumbing out of the shared planner.
|
||||
func resolveECRatio(_ *types.ClusterInfo, _ string) (int, int) {
|
||||
// Custom EC ratios are an enterprise feature; OSS uses the standard scheme.
|
||||
return normalizeECShardCounts(0, 0)
|
||||
}
|
||||
|
||||
// resolveReplicaPlacement picks the EC shard replica placement constraint: an
|
||||
// explicit config value wins; otherwise it falls back to the master's default
|
||||
// replication (matching the shell ec.balance default). A missing, invalid, or
|
||||
// zero-replication value yields nil, meaning even spread / no constraint.
|
||||
func resolveReplicaPlacement(ecConfig *Config, clusterInfo *types.ClusterInfo) *super_block.ReplicaPlacement {
|
||||
clusterDefault := ""
|
||||
if clusterInfo != nil {
|
||||
clusterDefault = clusterInfo.DefaultReplicaPlacement
|
||||
}
|
||||
return super_block.ResolveReplicaPlacement(ecConfig.ReplicaPlacement, clusterDefault)
|
||||
}
|
||||
|
||||
func normalizeECShardCounts(dataShards, parityShards int) (int, int) {
|
||||
if dataShards <= 0 {
|
||||
dataShards = erasure_coding.DataShardsCount
|
||||
}
|
||||
if parityShards <= 0 {
|
||||
parityShards = erasure_coding.ParityShardsCount
|
||||
}
|
||||
return dataShards, parityShards
|
||||
}
|
||||
|
||||
func countEcShards(ecShardInfos []*master_pb.VolumeEcShardInformationMessage) int {
|
||||
count := 0
|
||||
for _, eci := range ecShardInfos {
|
||||
count += erasure_coding.GetShardCount(eci)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func movePhasePriority(phase string) types.TaskPriority {
|
||||
switch phase {
|
||||
case "dedup":
|
||||
return types.TaskPriorityHigh
|
||||
case "cross_rack":
|
||||
return types.TaskPriorityMedium
|
||||
default:
|
||||
return types.TaskPriorityLow
|
||||
}
|
||||
}
|
||||
156
weed/worker/tasks/ec_balance/detection_test.go
Normal file
156
weed/worker/tasks/ec_balance/detection_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// The EC balance policy itself is tested in the shared ecbalancer package; these
|
||||
// tests cover the worker adapter: building the planner topology from the master
|
||||
// topology (filters, capacity) and the Detection entry point.
|
||||
|
||||
func ecTopo(node1Collection string) *master_pb.TopologyInfo {
|
||||
node1 := &master_pb.DataNodeInfo{
|
||||
Id: "node1",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {Type: "", MaxVolumeCount: 100, EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 100, Collection: node1Collection, DiskId: 0, EcIndexBits: 0x3FFF}, // 14 shards
|
||||
}},
|
||||
},
|
||||
}
|
||||
node2 := &master_pb.DataNodeInfo{
|
||||
Id: "node2",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"": {Type: "", MaxVolumeCount: 100}},
|
||||
}
|
||||
return &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{node1}},
|
||||
{Id: "rack2", DataNodeInfos: []*master_pb.DataNodeInfo{node2}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBalancerTopology(t *testing.T) {
|
||||
config := NewDefaultConfig()
|
||||
topo, nodeCount, _ := buildBalancerTopology(ecTopo("col1"), config, nil)
|
||||
if nodeCount != 2 {
|
||||
t.Fatalf("nodeCount = %d, want 2", nodeCount)
|
||||
}
|
||||
moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01})
|
||||
if len(moves) == 0 {
|
||||
t.Error("expected cross-rack moves for an all-on-one-rack volume")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildBalancerTopologyGroupsByHost: two volume servers on host 10.0.0.1
|
||||
// (different ports) plus three other hosts, a 10+4 volume concentrated on the
|
||||
// 10.0.0.1 machine. Four machines is enough to spread within parity, so after
|
||||
// planning the 10.0.0.1 machine must hold <=4 shards of the volume -- which only
|
||||
// holds if its two ports are grouped into one machine (host wired into the build).
|
||||
func TestBuildBalancerTopologyGroupsByHost(t *testing.T) {
|
||||
mkNode := func(id string, bits uint32) *master_pb.DataNodeInfo {
|
||||
di := &master_pb.DiskInfo{Type: "", MaxVolumeCount: 100}
|
||||
if bits != 0 {
|
||||
di.EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{{Id: 100, Collection: "col1", DiskId: 0, EcIndexBits: bits}}
|
||||
}
|
||||
return &master_pb.DataNodeInfo{Id: id, DiskInfos: map[string]*master_pb.DiskInfo{"": di}}
|
||||
}
|
||||
topoInfo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
mkNode("10.0.0.1:8080", 0x007F), // shards 0-6 on host 10.0.0.1
|
||||
mkNode("10.0.0.1:8081", 0x3F80), // shards 7-13 on host 10.0.0.1
|
||||
mkNode("10.0.0.2:8080", 0),
|
||||
mkNode("10.0.0.3:8080", 0),
|
||||
mkNode("10.0.0.4:8080", 0),
|
||||
}}},
|
||||
}},
|
||||
}
|
||||
|
||||
topo, _, _ := buildBalancerTopology(topoInfo, NewDefaultConfig(), nil)
|
||||
moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01})
|
||||
|
||||
host := func(nodeID string) string { h, _, _ := net.SplitHostPort(nodeID); return h }
|
||||
count := map[string]int{"10.0.0.1": 14}
|
||||
for _, m := range moves {
|
||||
if m.VolumeID != 100 {
|
||||
continue
|
||||
}
|
||||
count[host(m.SourceNode)]--
|
||||
if m.SourceNode != m.TargetNode { // non-dedup move
|
||||
count[host(m.TargetNode)]++
|
||||
}
|
||||
}
|
||||
if count["10.0.0.1"] > 4 {
|
||||
t.Errorf("machine 10.0.0.1 holds %d shards of the volume after balancing, want <=4 (host grouping not applied)", count["10.0.0.1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBalancerTopologyCollectionFilter(t *testing.T) {
|
||||
config := NewDefaultConfig()
|
||||
config.CollectionFilter = "other" // does not match the volume's collection
|
||||
allowed, err := wildcard.CompileCollectionMatcher(config.CollectionFilter)
|
||||
if err != nil {
|
||||
t.Fatalf("CompileCollectionMatcher: %v", err)
|
||||
}
|
||||
topo, nodeCount, _ := buildBalancerTopology(ecTopo("col1"), config, allowed)
|
||||
if nodeCount != 2 {
|
||||
t.Fatalf("nodeCount = %d, want 2", nodeCount)
|
||||
}
|
||||
if moves := ecbalancer.Plan(topo, ecbalancer.Options{ImbalanceThreshold: 0.01}); len(moves) != 0 {
|
||||
t.Errorf("filtered-out collection should produce no moves, got %d", len(moves))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectionDisabled(t *testing.T) {
|
||||
config := NewDefaultConfig()
|
||||
config.Enabled = false
|
||||
|
||||
results, hasMore, err := Detection(context.Background(), nil, nil, config, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hasMore {
|
||||
t.Error("expected hasMore=false")
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectionNilTopology(t *testing.T) {
|
||||
config := NewDefaultConfig()
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: nil}
|
||||
|
||||
if _, _, err := Detection(context.Background(), nil, clusterInfo, config, 0); err == nil {
|
||||
t.Fatal("expected error for nil topology")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovePhasePriority(t *testing.T) {
|
||||
cases := map[string]types.TaskPriority{
|
||||
"dedup": types.TaskPriorityHigh,
|
||||
"cross_rack": types.TaskPriorityMedium,
|
||||
"within_rack": types.TaskPriorityLow,
|
||||
"global": types.TaskPriorityLow,
|
||||
}
|
||||
for phase, want := range cases {
|
||||
if got := movePhasePriority(phase); got != want {
|
||||
t.Errorf("movePhasePriority(%q) = %v, want %v", phase, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keep the erasure_coding import meaningful for future adapter tests
|
||||
var _ = erasure_coding.DataShardsCount
|
||||
230
weed/worker/tasks/ec_balance/ec_balance_task.go
Normal file
230
weed/worker/tasks/ec_balance/ec_balance_task.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation/volume_move"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// ECBalanceTask implements a single EC shard move operation.
|
||||
// The move sequence — copy+mount on dest, verify the dest registered the
|
||||
// shards, then unmount+delete on the source — is shared with the shell's
|
||||
// ec.balance command via weed/operation/volume_move.
|
||||
type ECBalanceTask struct {
|
||||
*base.BaseTask
|
||||
volumeID uint32
|
||||
collection string
|
||||
grpcDialOption grpc.DialOption
|
||||
progress uint64 // atomic; stores float64 bits via math.Float64bits
|
||||
reporting int32 // atomic; re-entry guard to prevent recursive reportProgress calls
|
||||
}
|
||||
|
||||
// NewECBalanceTask creates a new EC balance task instance
|
||||
func NewECBalanceTask(id string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *ECBalanceTask {
|
||||
return &ECBalanceTask{
|
||||
BaseTask: base.NewBaseTask(id, types.TaskTypeECBalance),
|
||||
volumeID: volumeID,
|
||||
collection: collection,
|
||||
grpcDialOption: grpcDialOption,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute performs the EC shard move operation.
|
||||
func (t *ECBalanceTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return fmt.Errorf("task parameters are required")
|
||||
}
|
||||
|
||||
if len(params.Sources) == 0 || len(params.Targets) == 0 {
|
||||
return fmt.Errorf("sources and targets are required for EC shard move")
|
||||
}
|
||||
if len(params.Sources) > 1 || len(params.Targets) > 1 {
|
||||
return fmt.Errorf("batch EC shard moves not supported: got %d sources and %d targets, expected 1 each", len(params.Sources), len(params.Targets))
|
||||
}
|
||||
|
||||
source := params.Sources[0]
|
||||
target := params.Targets[0]
|
||||
|
||||
if len(source.ShardIds) == 0 || len(target.ShardIds) == 0 {
|
||||
return fmt.Errorf("shard IDs are required in sources and targets")
|
||||
}
|
||||
|
||||
sourceAddr := pb.ServerAddress(source.Node)
|
||||
targetAddr := pb.ServerAddress(target.Node)
|
||||
// Range-check before the uint8 narrowing in Uint32ToShardIds: a malformed
|
||||
// id like 259 would otherwise alias shard 3 and copy/delete a real,
|
||||
// unrelated shard.
|
||||
if err := checkShardIdRange(source.ShardIds); err != nil {
|
||||
return err
|
||||
}
|
||||
shardIds := erasure_coding.Uint32ToShardIds(source.ShardIds)
|
||||
|
||||
ecParams := params.GetEcBalanceParams()
|
||||
|
||||
// Apply configured timeout to the context for all RPC operations
|
||||
if ecParams != nil && ecParams.TimeoutSeconds > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(ecParams.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
isDedupDelete := ecParams != nil && isDedupPhase(params)
|
||||
|
||||
// Guard against a same-node, cross-disk "move". The shared mover skips the
|
||||
// copy when source and target addresses match, but the EC shard delete is
|
||||
// node-wide (it removes the shard from every disk on the node), so this
|
||||
// sequence would erase the shard after never copying it. EC shards also
|
||||
// cannot be relocated between disks of one node via these RPCs, so such a
|
||||
// move is meaningless. Reject it rather than lose data.
|
||||
if source.Node == target.Node && source.DiskId != target.DiskId {
|
||||
return fmt.Errorf("refusing same-node cross-disk EC shard move for volume %d shard(s) %v on %s (source disk %d, target disk %d): EC shard delete is node-wide and would erase the shard after a skipped copy",
|
||||
params.VolumeId, source.ShardIds, source.Node, source.DiskId, target.DiskId)
|
||||
}
|
||||
|
||||
glog.Infof("EC balance: moving shard(s) %v of volume %d from %s to %s",
|
||||
source.ShardIds, params.VolumeId, source.Node, target.Node)
|
||||
|
||||
mover := volume_move.NewMover(t.grpcDialOption)
|
||||
|
||||
// For dedup, we only unmount+delete from source (no copy needed)
|
||||
if isDedupDelete {
|
||||
// Nothing is copied first, so the shard surviving elsewhere is the
|
||||
// only thing making this safe — and the plan asserting so is not
|
||||
// evidence. The topology can name a location that holds nothing, and
|
||||
// deleting on that basis removes the last copy. Confirm the keep node
|
||||
// has it before deleting here.
|
||||
if err := t.verifyShardsOnKeepNode(ctx, params.VolumeId, ecParams.GetDedupKeepNode(), source.ShardIds); err != nil {
|
||||
return err
|
||||
}
|
||||
t.reportProgress(25.0, "Removing duplicate EC shard")
|
||||
if err := mover.RemoveEcShards(ctx, needle.VolumeId(params.VolumeId), t.collection, sourceAddr, shardIds); err != nil {
|
||||
return fmt.Errorf("remove duplicate shard: %w", err)
|
||||
}
|
||||
t.reportProgress(100.0, "Duplicate shard removed")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := mover.MoveEcShards(ctx, volume_move.EcShardMove{
|
||||
VolumeId: needle.VolumeId(params.VolumeId),
|
||||
Collection: params.Collection,
|
||||
ShardIds: shardIds,
|
||||
Source: sourceAddr,
|
||||
Target: targetAddr,
|
||||
TargetDisk: target.DiskId,
|
||||
}, volume_move.EcMoveOptions{
|
||||
IoBytePerSecond: ecParams.GetIoBytePerSecond(),
|
||||
Progress: t.reportProgress,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof("EC balance: successfully moved shard(s) %v of volume %d from %s to %s",
|
||||
source.ShardIds, params.VolumeId, source.Node, target.Node)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyShardsOnKeepNode confirms the node the plan wants to keep the shard on
|
||||
// actually has every shard about to be deleted elsewhere, for this collection.
|
||||
func (t *ECBalanceTask) verifyShardsOnKeepNode(ctx context.Context, volumeID uint32, keepNode string, shardIDs []uint32) error {
|
||||
if keepNode == "" {
|
||||
return fmt.Errorf("refusing dedup delete of volume %d shard(s) %v: no keep node recorded, so no surviving copy can be confirmed", volumeID, shardIDs)
|
||||
}
|
||||
if err := erasure_coding.VerifyShardsOnServer(ctx, t.collection, volumeID,
|
||||
string(pb.ServerAddress(keepNode)), shardIDs, t.grpcDialOption); err != nil {
|
||||
return fmt.Errorf("refusing dedup delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the task parameters.
|
||||
// ECBalanceTask handles exactly one source→target shard move per execution.
|
||||
func (t *ECBalanceTask) Validate(params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: TaskParams are required")
|
||||
}
|
||||
if len(params.Sources) != 1 {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: expected exactly 1 source, got %d", len(params.Sources))
|
||||
}
|
||||
if len(params.Targets) != 1 {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: expected exactly 1 target, got %d", len(params.Targets))
|
||||
}
|
||||
if len(params.Sources[0].ShardIds) == 0 {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: Sources[0].ShardIds is empty")
|
||||
}
|
||||
if len(params.Targets[0].ShardIds) == 0 {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: Targets[0].ShardIds is empty")
|
||||
}
|
||||
if err := checkShardIdRange(params.Sources[0].ShardIds); err != nil {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: %v", err)
|
||||
}
|
||||
if err := checkShardIdRange(params.Targets[0].ShardIds); err != nil {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: %v", err)
|
||||
}
|
||||
// A same-node, cross-disk move is unsafe: the node-wide EC shard delete would
|
||||
// erase the shard after the skipped same-address copy. Such a move cannot be
|
||||
// expressed by these RPCs anyway. Dedup (same node and disk) is allowed.
|
||||
if params.Sources[0].Node == params.Targets[0].Node && params.Sources[0].DiskId != params.Targets[0].DiskId {
|
||||
return fmt.Errorf("ECBalanceTask.Validate: refusing same-node cross-disk move on %s (source disk %d, target disk %d): EC shard delete is node-wide",
|
||||
params.Sources[0].Node, params.Sources[0].DiskId, params.Targets[0].DiskId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateTime estimates the time for an EC shard move
|
||||
func (t *ECBalanceTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
// GetProgress returns current progress
|
||||
func (t *ECBalanceTask) GetProgress() float64 {
|
||||
return math.Float64frombits(atomic.LoadUint64(&t.progress))
|
||||
}
|
||||
|
||||
// reportProgress updates the stored progress and reports it via the callback
|
||||
func (t *ECBalanceTask) reportProgress(progress float64, stage string) {
|
||||
if !atomic.CompareAndSwapInt32(&t.reporting, 0, 1) {
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&t.reporting, 0)
|
||||
atomic.StoreUint64(&t.progress, math.Float64bits(progress))
|
||||
t.ReportProgressWithStage(progress, stage)
|
||||
glog.Infof("EC balance volume %d: [%.2f] %s", t.volumeID, progress, stage)
|
||||
}
|
||||
|
||||
// checkShardIdRange rejects shard ids that would alias a real shard when
|
||||
// narrowed to the uint8 ShardId (e.g. 259 → 3).
|
||||
func checkShardIdRange(ids []uint32) error {
|
||||
for _, id := range ids {
|
||||
if id >= erasure_coding.MaxShardCount {
|
||||
return fmt.Errorf("shard id %d out of range (max %d)", id, erasure_coding.MaxShardCount-1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDedupPhase checks if this is a dedup-phase task: an unmount+delete on a
|
||||
// single location, encoded by detection as source==target on the same node AND
|
||||
// the same disk. Comparing the disk too is essential — VolumeEcShardsDelete is
|
||||
// node-wide (it removes the shard from every disk on the node), so a same-node
|
||||
// but cross-disk request must NOT be treated as a benign dedup; see Validate
|
||||
// and Execute, which reject it outright.
|
||||
func isDedupPhase(params *worker_pb.TaskParams) bool {
|
||||
if len(params.Sources) > 0 && len(params.Targets) > 0 {
|
||||
s, t := params.Sources[0], params.Targets[0]
|
||||
return s.Node == t.Node && s.DiskId == t.DiskId
|
||||
}
|
||||
return false
|
||||
}
|
||||
27
weed/worker/tasks/ec_balance/ec_balance_task_test.go
Normal file
27
weed/worker/tasks/ec_balance/ec_balance_task_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
)
|
||||
|
||||
func TestECBalanceTaskRejectsOutOfRangeShardIds(t *testing.T) {
|
||||
// ShardId is a uint8; an unchecked id like 259 would alias shard 3 and
|
||||
// copy/delete a real, unrelated shard.
|
||||
params := &worker_pb.TaskParams{
|
||||
VolumeId: 7,
|
||||
Sources: []*worker_pb.TaskSource{{Node: "src:8080", ShardIds: []uint32{259}}},
|
||||
Targets: []*worker_pb.TaskTarget{{Node: "dst:8080", ShardIds: []uint32{259}}},
|
||||
}
|
||||
task := NewECBalanceTask("t1", 7, "c1", nil)
|
||||
|
||||
if err := task.Validate(params); err == nil || !strings.Contains(err.Error(), "out of range") {
|
||||
t.Fatalf("Validate: expected out-of-range rejection, got: %v", err)
|
||||
}
|
||||
if err := task.Execute(context.Background(), params); err == nil || !strings.Contains(err.Error(), "out of range") {
|
||||
t.Fatalf("Execute: expected out-of-range rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
140
weed/worker/tasks/ec_balance/fake_ec_volume_server_test.go
Normal file
140
weed/worker/tasks/ec_balance/fake_ec_volume_server_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
)
|
||||
|
||||
// fakeEcVolumeServer is a volume server that only knows about EC shard
|
||||
// inventory. It exists so the destructive half of a balance job can be driven
|
||||
// in-process: what a real volume server has on disk is the thing the master's
|
||||
// topology can be wrong about, and being wrong about it is what makes a delete
|
||||
// unsafe. Holding that state here lets a test say "the topology claims this,
|
||||
// the disk holds that" and watch what the job does.
|
||||
type fakeEcVolumeServer struct {
|
||||
volume_server_pb.UnimplementedVolumeServerServer
|
||||
|
||||
mu sync.Mutex
|
||||
// shards actually present on this server's disks, per volume.
|
||||
shards map[uint32]map[uint32]bool
|
||||
// collection the held volume belongs to. Reported back on the inventory so
|
||||
// a caller can tell this server's volume N from another collection's N.
|
||||
collection string
|
||||
// deleted/unmounted record what the job asked for, so a test can assert on
|
||||
// the request even when it was a no-op against the inventory.
|
||||
deleted []string
|
||||
unmounted []string
|
||||
// grpcAddr is host:port; the code under test dials by address string.
|
||||
grpcAddr string
|
||||
stop func()
|
||||
}
|
||||
|
||||
func (f *fakeEcVolumeServer) VolumeEcShardsInfo(ctx context.Context, req *volume_server_pb.VolumeEcShardsInfoRequest) (*volume_server_pb.VolumeEcShardsInfoResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
resp := &volume_server_pb.VolumeEcShardsInfoResponse{}
|
||||
for sid := range f.shards[req.VolumeId] {
|
||||
resp.EcShardInfos = append(resp.EcShardInfos, &volume_server_pb.EcShardInfo{
|
||||
VolumeId: req.VolumeId,
|
||||
ShardId: sid,
|
||||
Collection: f.collection,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (f *fakeEcVolumeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, sid := range req.ShardIds {
|
||||
f.deleted = append(f.deleted, fmt.Sprintf("%d.%d", req.VolumeId, sid))
|
||||
delete(f.shards[req.VolumeId], sid)
|
||||
}
|
||||
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEcVolumeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_server_pb.VolumeEcShardsUnmountRequest) (*volume_server_pb.VolumeEcShardsUnmountResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, sid := range req.ShardIds {
|
||||
f.unmounted = append(f.unmounted, fmt.Sprintf("%d.%d", req.VolumeId, sid))
|
||||
}
|
||||
return &volume_server_pb.VolumeEcShardsUnmountResponse{}, nil
|
||||
}
|
||||
|
||||
// has reports whether the shard is still on this server's disks.
|
||||
func (f *fakeEcVolumeServer) has(volumeID, shardID uint32) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.shards[volumeID][shardID]
|
||||
}
|
||||
|
||||
func (f *fakeEcVolumeServer) deletedShards() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.deleted...)
|
||||
}
|
||||
|
||||
// startFakeEcVolumeServer listens on a loopback port and serves the EC subset
|
||||
// of the volume server API. present lists the shards this server really holds.
|
||||
func startFakeEcVolumeServer(t *testing.T, volumeID uint32, present ...uint32) *fakeEcVolumeServer {
|
||||
return startFakeEcVolumeServerInCollection(t, dedupTestCollection, volumeID, present...)
|
||||
}
|
||||
|
||||
// startFakeEcVolumeServerInCollection is the same, for a named collection, so a
|
||||
// test can stand up two servers holding the same volume id in different ones.
|
||||
func startFakeEcVolumeServerInCollection(t *testing.T, collection string, volumeID uint32, present ...uint32) *fakeEcVolumeServer {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeEcVolumeServer{
|
||||
shards: map[uint32]map[uint32]bool{volumeID: {}},
|
||||
collection: collection,
|
||||
grpcAddr: listener.Addr().String(),
|
||||
}
|
||||
for _, sid := range present {
|
||||
f.shards[volumeID][sid] = true
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
volume_server_pb.RegisterVolumeServerServer(server, f)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
f.stop = server.Stop
|
||||
t.Cleanup(server.Stop)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// address returns a node id in the "host:httpPort.grpcPort" form the cluster
|
||||
// uses (e.g. mg01-s3-intelistor-15:8087.18087), so ToGrpcAddress resolves to
|
||||
// this fake's listener. The http port is unused and only has to parse.
|
||||
func (f *fakeEcVolumeServer) address() string {
|
||||
host, port, err := net.SplitHostPort(f.grpcAddr)
|
||||
if err != nil {
|
||||
return f.grpcAddr
|
||||
}
|
||||
return fmt.Sprintf("%s:1.%s", host, port)
|
||||
}
|
||||
|
||||
// shardBitsOf is a small readability helper for asserting inventory.
|
||||
func shardBitsOf(f *fakeEcVolumeServer, volumeID uint32) erasure_coding.ShardBits {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var b erasure_coding.ShardBits
|
||||
for sid := range f.shards[volumeID] {
|
||||
b = b.Set(erasure_coding.ShardId(sid))
|
||||
}
|
||||
return b
|
||||
}
|
||||
335
weed/worker/tasks/ec_balance/multidisk_detection_test.go
Normal file
335
weed/worker/tasks/ec_balance/multidisk_detection_test.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// In-process (no real cluster) tests of the detection/planning path against a
|
||||
// topology shaped the way the master reports a multi-disk cluster: several
|
||||
// same-type physical disks on a node collapse into a single DiskInfo, with each
|
||||
// shard's real DiskId surviving only in the per-shard records (issue 9593). They
|
||||
// run the master-wire-format -> ActiveTopology -> Detection path, then simulate
|
||||
// executing the planned moves with the volume server's actual semantics
|
||||
// (VolumeEcShardsDelete is node-wide) to assert no EC shard is ever lost. The
|
||||
// real-cluster end-to-end equivalent lives in test/erasure_coding.
|
||||
|
||||
// nodeSpec describes one volume server: its rack and the volume's shards per
|
||||
// physical disk (diskID -> shard ids).
|
||||
type nodeSpec struct {
|
||||
id string
|
||||
rack string
|
||||
disks map[uint32][]int
|
||||
}
|
||||
|
||||
const integDisksPerNode = 6
|
||||
|
||||
// buildMasterTopology renders nodeSpecs into a *master_pb.TopologyInfo exactly as
|
||||
// the master would: one DiskInfo per node keyed by disk type (""), carrying one
|
||||
// EcShardInfo per (volume, physical disk) plus regular VolumeInfos so every
|
||||
// physical disk is discoverable even when it holds no EC shards.
|
||||
func buildMasterTopology(collection string, vid uint32, maxVolPerDisk int, specs []nodeSpec) *master_pb.TopologyInfo {
|
||||
rackByID := map[string]*master_pb.RackInfo{}
|
||||
var rackOrder []string
|
||||
|
||||
for _, spec := range specs {
|
||||
var ecShards []*master_pb.VolumeEcShardInformationMessage
|
||||
for diskID, shards := range spec.disks {
|
||||
var bits erasure_coding.ShardBits
|
||||
for _, s := range shards {
|
||||
bits = bits.Set(erasure_coding.ShardId(s))
|
||||
}
|
||||
ecShards = append(ecShards, &master_pb.VolumeEcShardInformationMessage{
|
||||
Id: vid,
|
||||
Collection: collection,
|
||||
DiskId: diskID,
|
||||
EcIndexBits: uint32(bits),
|
||||
})
|
||||
}
|
||||
// Expose all physical disks (incl. EC-empty ones) via regular volumes.
|
||||
var volInfos []*master_pb.VolumeInformationMessage
|
||||
for d := uint32(0); d < integDisksPerNode; d++ {
|
||||
volInfos = append(volInfos, &master_pb.VolumeInformationMessage{Id: 90000 + d, DiskId: d})
|
||||
}
|
||||
dn := &master_pb.DataNodeInfo{
|
||||
Id: spec.id,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {
|
||||
Type: "",
|
||||
MaxVolumeCount: int64(maxVolPerDisk * integDisksPerNode),
|
||||
VolumeCount: int64(integDisksPerNode),
|
||||
EcShardInfos: ecShards,
|
||||
VolumeInfos: volInfos,
|
||||
},
|
||||
},
|
||||
}
|
||||
r, ok := rackByID[spec.rack]
|
||||
if !ok {
|
||||
r = &master_pb.RackInfo{Id: spec.rack}
|
||||
rackByID[spec.rack] = r
|
||||
rackOrder = append(rackOrder, spec.rack)
|
||||
}
|
||||
r.DataNodeInfos = append(r.DataNodeInfos, dn)
|
||||
}
|
||||
|
||||
dc := &master_pb.DataCenterInfo{Id: "dc1"}
|
||||
for _, rk := range rackOrder {
|
||||
dc.RackInfos = append(dc.RackInfos, rackByID[rk])
|
||||
}
|
||||
return &master_pb.TopologyInfo{Id: "integ", DataCenterInfos: []*master_pb.DataCenterInfo{dc}}
|
||||
}
|
||||
|
||||
// runDetection wires the topology through a real ActiveTopology and runs Detection.
|
||||
func runDetection(t *testing.T, topoInfo *master_pb.TopologyInfo, cfg *Config) []*types.TaskDetectionResult {
|
||||
t.Helper()
|
||||
at := topology.NewActiveTopology(0)
|
||||
if err := at.UpdateTopology(topoInfo); err != nil {
|
||||
t.Fatalf("UpdateTopology: %v", err)
|
||||
}
|
||||
results, _, err := Detection(context.Background(), nil, &types.ClusterInfo{ActiveTopology: at}, cfg, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Detection: %v", err)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// shardModel is nodeID -> diskID -> set of shard ids for one volume.
|
||||
type shardModel map[string]map[uint32]map[int]bool
|
||||
|
||||
func modelFromSpecs(specs []nodeSpec) shardModel {
|
||||
m := make(shardModel)
|
||||
for _, spec := range specs {
|
||||
m[spec.id] = make(map[uint32]map[int]bool)
|
||||
for diskID, shards := range spec.disks {
|
||||
set := make(map[int]bool)
|
||||
for _, s := range shards {
|
||||
set[s] = true
|
||||
}
|
||||
m[spec.id][diskID] = set
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m shardModel) distinctShards() []int {
|
||||
seen := map[int]bool{}
|
||||
for _, disks := range m {
|
||||
for _, set := range disks {
|
||||
for s := range set {
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// applyMovesRealistically replays planned moves with the volume server's actual
|
||||
// behavior: a move copies the shard to the destination disk, then deletes it on
|
||||
// the source node — and EC shard delete is node-wide (removes the shard from
|
||||
// every disk on the source node). A dedup move (same node+disk) is delete-only.
|
||||
func (m shardModel) apply(results []*types.TaskDetectionResult) {
|
||||
nodeWideDelete := func(node string, shard int) {
|
||||
for diskID := range m[node] {
|
||||
delete(m[node][diskID], shard)
|
||||
}
|
||||
}
|
||||
for _, r := range results {
|
||||
p := r.TypedParams
|
||||
if p == nil || len(p.Sources) == 0 || len(p.Targets) == 0 {
|
||||
continue
|
||||
}
|
||||
src, dst := p.Sources[0], p.Targets[0]
|
||||
if len(src.ShardIds) == 0 {
|
||||
continue
|
||||
}
|
||||
shard := int(src.ShardIds[0])
|
||||
dedup := src.Node == dst.Node && src.DiskId == dst.DiskId
|
||||
if !dedup {
|
||||
if m[dst.Node] == nil {
|
||||
m[dst.Node] = make(map[uint32]map[int]bool)
|
||||
}
|
||||
if m[dst.Node][dst.DiskId] == nil {
|
||||
m[dst.Node][dst.DiskId] = make(map[int]bool)
|
||||
}
|
||||
m[dst.Node][dst.DiskId][shard] = true
|
||||
}
|
||||
nodeWideDelete(src.Node, shard)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiDiskBalanceNeverLosesShards is the core regression for
|
||||
// issue 9593: a freshly-encoded volume on a 3-node, 6-disk-per-node cluster,
|
||||
// balanced and then concentrated, must never lose a shard when the planned moves
|
||||
// are executed with real node-wide-delete semantics.
|
||||
func TestMultiDiskBalanceNeverLosesShards(t *testing.T) {
|
||||
cfg := NewDefaultConfig()
|
||||
cfg.ImbalanceThreshold = 0.0 // balance to even; surface any move the planner makes
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
specs []nodeSpec
|
||||
}{
|
||||
{
|
||||
// Healthy post-encode spread: 14 shards across 3 nodes (5/5/4), each
|
||||
// node spreading its shards over distinct physical disks.
|
||||
name: "balanced across disks",
|
||||
specs: []nodeSpec{
|
||||
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0}, 1: {1}, 2: {2}, 3: {3}, 4: {4}}},
|
||||
{id: "n2", rack: "rack1", disks: map[uint32][]int{0: {5}, 1: {6}, 2: {7}, 3: {8}, 4: {9}}},
|
||||
{id: "n3", rack: "rack1", disks: map[uint32][]int{0: {10}, 1: {11}, 2: {12}, 3: {13}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Worst case from the report: all 14 shards landed on one node's disks;
|
||||
// the balancer must redistribute without losing any.
|
||||
name: "concentrated on one node",
|
||||
specs: []nodeSpec{
|
||||
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}},
|
||||
{id: "n2", rack: "rack2", disks: map[uint32][]int{}},
|
||||
{id: "n3", rack: "rack3", disks: map[uint32][]int{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Multi-rack healthy spread.
|
||||
name: "spread across racks",
|
||||
specs: []nodeSpec{
|
||||
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1}, 1: {2, 3}, 2: {4}}},
|
||||
{id: "n2", rack: "rack2", disks: map[uint32][]int{0: {5, 6}, 1: {7, 8}, 2: {9}}},
|
||||
{id: "n3", rack: "rack3", disks: map[uint32][]int{0: {10, 11}, 1: {12, 13}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
topoInfo := buildMasterTopology("col1", 28, 50, tc.specs)
|
||||
results := runDetection(t, topoInfo, cfg)
|
||||
|
||||
model := modelFromSpecs(tc.specs)
|
||||
before := model.distinctShards()
|
||||
|
||||
for _, r := range results {
|
||||
// No move may be a same-node cross-disk move (node-wide delete
|
||||
// would erase the shard after a skipped copy).
|
||||
p := r.TypedParams
|
||||
s, d := p.Sources[0], p.Targets[0]
|
||||
if s.Node == d.Node && s.DiskId != d.DiskId {
|
||||
t.Errorf("unsafe same-node cross-disk move: vol %d shard %v on %s disk %d->%d", p.VolumeId, s.ShardIds, s.Node, s.DiskId, d.DiskId)
|
||||
}
|
||||
// Source disk must actually hold the shard being moved.
|
||||
shard := int(s.ShardIds[0])
|
||||
if !model[s.Node][s.DiskId][shard] && s.Node != d.Node {
|
||||
t.Errorf("move sources vol %d shard %d from %s disk %d, which does not hold it", p.VolumeId, shard, s.Node, s.DiskId)
|
||||
}
|
||||
}
|
||||
|
||||
model.apply(results)
|
||||
after := model.distinctShards()
|
||||
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("[%s] shard loss: had %v (%d), now %v (%d) after %d moves",
|
||||
tc.name, before, len(before), after, len(after), len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcentratedVolumeSpreadsAcrossNodesAndDisks asserts the
|
||||
// remediation actually happens: a one-node-concentrated volume is redistributed
|
||||
// to the other racks, landing on multiple distinct destination disks.
|
||||
func TestConcentratedVolumeSpreadsAcrossNodesAndDisks(t *testing.T) {
|
||||
cfg := NewDefaultConfig()
|
||||
cfg.ImbalanceThreshold = 0.0
|
||||
|
||||
specs := []nodeSpec{
|
||||
{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}},
|
||||
{id: "n2", rack: "rack2", disks: map[uint32][]int{}},
|
||||
{id: "n3", rack: "rack3", disks: map[uint32][]int{}},
|
||||
}
|
||||
results := runDetection(t, buildMasterTopology("col1", 28, 50, specs), cfg)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected redistribution moves for a one-node-concentrated volume")
|
||||
}
|
||||
|
||||
destNodes := map[string]bool{}
|
||||
destDisksByNode := map[string]map[uint32]bool{}
|
||||
for _, r := range results {
|
||||
d := r.TypedParams.Targets[0]
|
||||
if d.Node == "n1" {
|
||||
continue
|
||||
}
|
||||
destNodes[d.Node] = true
|
||||
if destDisksByNode[d.Node] == nil {
|
||||
destDisksByNode[d.Node] = map[uint32]bool{}
|
||||
}
|
||||
destDisksByNode[d.Node][d.DiskId] = true
|
||||
}
|
||||
if len(destNodes) < 2 {
|
||||
t.Errorf("shards spread to only %d destination nodes, want both other racks", len(destNodes))
|
||||
}
|
||||
for node, disks := range destDisksByNode {
|
||||
if len(disks) < 2 {
|
||||
t.Errorf("destination %s received shards on only %d disk(s); expected spread across disks: %v", node, len(disks), disks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildBalancerTopologyNormalizesHddDiskType guards the disk-type filter:
|
||||
// the master reports default-HDD disks under the empty-string key, so a config of
|
||||
// "hdd" must match them (not filter everything out), while "ssd" must exclude them.
|
||||
func TestBuildBalancerTopologyNormalizesHddDiskType(t *testing.T) {
|
||||
specs := []nodeSpec{
|
||||
{id: "n1", rack: "r1", disks: map[uint32][]int{0: {0, 1}}},
|
||||
{id: "n2", rack: "r1", disks: map[uint32][]int{0: {2}}},
|
||||
}
|
||||
topoInfo := buildMasterTopology("c", 100, 50, specs)
|
||||
|
||||
if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: "hdd"}, nil); n != 2 {
|
||||
t.Errorf("disk_type=hdd matched %d nodes on an all-HDD cluster, want 2 (hdd must map to the empty HDD key)", n)
|
||||
}
|
||||
if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: ""}, nil); n != 2 {
|
||||
t.Errorf("disk_type=empty matched %d nodes, want 2 (all)", n)
|
||||
}
|
||||
if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: "ssd"}, nil); n != 0 {
|
||||
t.Errorf("disk_type=ssd matched %d nodes on an all-HDD cluster, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveReplicaPlacementFallsBackToMasterDefault verifies the worker mirrors
|
||||
// the shell: explicit config wins, otherwise the master's default replication is
|
||||
// the fallback, and an empty or zero-replication value means no constraint.
|
||||
func TestResolveReplicaPlacementFallsBackToMasterDefault(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
configRP string
|
||||
defaultRP string
|
||||
wantApplied bool
|
||||
}{
|
||||
{"explicit config used", "010", "", true},
|
||||
{"explicit config wins over default", "010", "100", true},
|
||||
{"falls back to master default", "", "010", true},
|
||||
{"zero master default = no constraint", "", "000", false},
|
||||
{"empty everywhere = no constraint", "", "", false},
|
||||
{"invalid value ignored", "", "nonsense", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rp := resolveReplicaPlacement(&Config{ReplicaPlacement: tc.configRP},
|
||||
&types.ClusterInfo{DefaultReplicaPlacement: tc.defaultRP})
|
||||
if (rp != nil) != tc.wantApplied {
|
||||
t.Errorf("config=%q default=%q: applied=%v, want %v", tc.configRP, tc.defaultRP, rp != nil, tc.wantApplied)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
weed/worker/tasks/ec_balance/multidisk_test.go
Normal file
58
weed/worker/tasks/ec_balance/multidisk_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
)
|
||||
|
||||
// These cover the worker execution layer (ec_balance_task.go). The shard-balance
|
||||
// policy and multi-disk placement are tested in the shared ecbalancer package.
|
||||
|
||||
// TestValidateRejectsSameNodeCrossDiskMove covers the data-loss trap of a
|
||||
// same-node, cross-disk shard move. copyAndMountShard skips the copy when source
|
||||
// and target addresses match, but VolumeEcShardsDelete is node-wide, so executing
|
||||
// such a move would erase the shard. Validate must reject it, while allowing a
|
||||
// cross-node move and a same-node/same-disk dedup.
|
||||
func TestValidateRejectsSameNodeCrossDiskMove(t *testing.T) {
|
||||
task := NewECBalanceTask("t1", 100, "col1", nil)
|
||||
|
||||
mk := func(srcNode string, srcDisk uint32, dstNode string, dstDisk uint32) *worker_pb.TaskParams {
|
||||
return &worker_pb.TaskParams{
|
||||
VolumeId: 100,
|
||||
Sources: []*worker_pb.TaskSource{{Node: srcNode, DiskId: srcDisk, ShardIds: []uint32{3}}},
|
||||
Targets: []*worker_pb.TaskTarget{{Node: dstNode, DiskId: dstDisk, ShardIds: []uint32{3}}},
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.Validate(mk("node1", 0, "node1", 3)); err == nil {
|
||||
t.Error("Validate accepted a same-node cross-disk move; it must reject it to avoid node-wide delete data loss")
|
||||
}
|
||||
if err := task.Validate(mk("node1", 0, "node2", 0)); err != nil {
|
||||
t.Errorf("Validate rejected a legitimate cross-node move: %v", err)
|
||||
}
|
||||
if err := task.Validate(mk("node1", 2, "node1", 2)); err != nil {
|
||||
t.Errorf("Validate rejected a same-node/same-disk dedup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsDedupPhaseRequiresSameDisk confirms dedup classification keys on both
|
||||
// node and disk, so a same-node cross-disk request is never silently routed to
|
||||
// the unmount+delete path.
|
||||
func TestIsDedupPhaseRequiresSameDisk(t *testing.T) {
|
||||
withParams := func(srcDisk, dstDisk uint32) *worker_pb.TaskParams {
|
||||
return &worker_pb.TaskParams{
|
||||
Sources: []*worker_pb.TaskSource{{Node: "node1", DiskId: srcDisk}},
|
||||
Targets: []*worker_pb.TaskTarget{{Node: "node1", DiskId: dstDisk}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{},
|
||||
},
|
||||
}
|
||||
}
|
||||
if !isDedupPhase(withParams(2, 2)) {
|
||||
t.Error("same node and disk should be a dedup phase")
|
||||
}
|
||||
if isDedupPhase(withParams(0, 3)) {
|
||||
t.Error("same node but different disk must NOT be a dedup phase")
|
||||
}
|
||||
}
|
||||
687
weed/worker/tasks/ec_balance/plugin_handler.go
Normal file
687
weed/worker/tasks/ec_balance/plugin_handler.go
Normal file
@@ -0,0 +1,687 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
ecBalanceMinImbalanceThreshold = 0.05
|
||||
ecBalanceMaxImbalanceThreshold = 0.5
|
||||
ecBalanceMinServerCount = 2
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
||||
JobType: "ec_balance",
|
||||
Category: pluginworker.CategoryDefault,
|
||||
Aliases: []string{"ec-balance", "ec.balance", "ec_shard_balance"},
|
||||
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
||||
return NewECBalanceHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type ecBalanceWorkerConfig struct {
|
||||
TaskConfig *Config
|
||||
}
|
||||
|
||||
// ECBalanceHandler is the plugin job handler for EC shard balancing.
|
||||
type ECBalanceHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
func NewECBalanceHandler(grpcDialOption grpc.DialOption) *ECBalanceHandler {
|
||||
return &ECBalanceHandler{grpcDialOption: grpcDialOption}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: "ec_balance",
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 3,
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Balance EC shard distribution across racks and nodes",
|
||||
Weight: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: "ec_balance",
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Detect and execute EC shard rebalancing across the cluster",
|
||||
Icon: "fas fa-balance-scale-left",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "ec-balance-admin",
|
||||
Title: "EC Shard Balance Admin Config",
|
||||
Description: "Admin-side controls for EC shard balance detection scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Optional filters applied before EC shard balance detection.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only balance EC shards in matching collections. Comma-separated list of names, wildcards, or regex patterns.",
|
||||
Placeholder: "all collections",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only balance within matching data centers (wildcard supported).",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "disk_type",
|
||||
Label: "Disk Type",
|
||||
Description: "Only balance EC shards on this disk type (hdd, ssd, or empty for all).",
|
||||
Placeholder: "all disk types",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "shard_replica_placement",
|
||||
Label: "Shard Replica Placement",
|
||||
Description: "EC shard replica placement constraint (e.g. 020); empty uses the master default replication (even spread only when that default is empty or zero). Mirrors the ec.balance -shardReplicaPlacement flag.",
|
||||
Placeholder: "master default",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"collection_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"data_center_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"disk_type": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"shard_replica_placement": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "ec-balance-worker",
|
||||
Title: "EC Shard Balance Worker Config",
|
||||
Description: "Worker-side detection thresholds and execution controls.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "thresholds",
|
||||
Title: "Detection Thresholds",
|
||||
Description: "Controls for when EC shard balance jobs should be proposed.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "imbalance_threshold",
|
||||
Label: "Imbalance Threshold",
|
||||
Description: "Minimum shard count imbalance ratio to trigger balancing (0.0-1.0).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMinImbalanceThreshold}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMaxImbalanceThreshold}},
|
||||
},
|
||||
{
|
||||
Name: "min_server_count",
|
||||
Label: "Minimum Server Count",
|
||||
Description: "Minimum servers required for EC shard balancing.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: ecBalanceMinServerCount}},
|
||||
},
|
||||
{
|
||||
Name: "io_byte_per_second",
|
||||
Label: "Shard Copy IO Limit (bytes/sec)",
|
||||
Description: "Limit each EC shard copy's rate in bytes per second. 0 falls back to each volume server's own maintenance rate.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: "preferred_tags",
|
||||
Label: "Preferred Tags",
|
||||
Description: "Comma-separated disk tags to prioritize for shard placement, ordered by preference.",
|
||||
Placeholder: "fast,ssd",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"io_byte_per_second": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalMinutes: 30,
|
||||
DetectionTimeoutSeconds: 300,
|
||||
MaxJobsPerDetection: 500,
|
||||
GlobalExecutionConcurrency: 16,
|
||||
PerWorkerExecutionConcurrency: 4,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 30,
|
||||
JobTypeMaxRuntimeSeconds: 1800,
|
||||
ExecutionTimeoutSeconds: 1800,
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"io_byte_per_second": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Detect(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.RunDetectionRequest,
|
||||
sender pluginworker.DetectionSender,
|
||||
) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != "ec_balance" {
|
||||
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := deriveECBalanceWorkerConfig(request.GetWorkerConfigValues())
|
||||
|
||||
// Apply admin-side scope filters
|
||||
collectionFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
|
||||
if collectionFilter != "" {
|
||||
workerConfig.TaskConfig.CollectionFilter = collectionFilter
|
||||
}
|
||||
dcFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
if dcFilter != "" {
|
||||
workerConfig.TaskConfig.DataCenterFilter = dcFilter
|
||||
}
|
||||
diskType := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "disk_type", ""))
|
||||
if diskType != "" {
|
||||
workerConfig.TaskConfig.DiskType = diskType
|
||||
}
|
||||
replicaPlacement := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "shard_replica_placement", ""))
|
||||
if replicaPlacement != "" {
|
||||
workerConfig.TaskConfig.ReplicaPlacement = replicaPlacement
|
||||
}
|
||||
|
||||
masters := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{
|
||||
ActiveTopology: activeTopology,
|
||||
DefaultReplicaPlacement: pluginworker.FetchDefaultReplicaPlacement(ctx, masters, h.grpcDialOption),
|
||||
}
|
||||
maxResults := int(request.MaxResults)
|
||||
if maxResults < 0 {
|
||||
maxResults = 0
|
||||
}
|
||||
|
||||
results, hasMore, err := Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if traceErr := emitECBalanceDecisionTrace(sender, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
|
||||
glog.Warningf("Plugin worker failed to emit ec_balance detection trace: %v", traceErr)
|
||||
}
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
|
||||
for _, result := range results {
|
||||
proposal, proposalErr := buildECBalanceProposal(result)
|
||||
if proposalErr != nil {
|
||||
glog.Warningf("Plugin worker skip invalid ec_balance proposal: %v", proposalErr)
|
||||
continue
|
||||
}
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "ec_balance",
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "ec_balance",
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Execute(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.ExecuteJobRequest,
|
||||
sender pluginworker.ExecutionSender,
|
||||
) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != "ec_balance" {
|
||||
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.Job.JobType)
|
||||
}
|
||||
|
||||
params, err := decodeECBalanceTaskParams(request.Job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return fmt.Errorf("ec balance source node is required")
|
||||
}
|
||||
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
|
||||
return fmt.Errorf("ec balance target node is required")
|
||||
}
|
||||
|
||||
task := NewECBalanceTask(
|
||||
request.Job.JobId,
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
h.grpcDialOption,
|
||||
)
|
||||
|
||||
execCtx, execCancel := context.WithCancel(ctx)
|
||||
defer execCancel()
|
||||
|
||||
task.SetProgressCallback(func(progress float64, stage string) {
|
||||
message := fmt.Sprintf("ec balance progress %.0f%%", progress)
|
||||
if strings.TrimSpace(stage) != "" {
|
||||
message = stage
|
||||
}
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity(stage, message),
|
||||
},
|
||||
}); err != nil {
|
||||
glog.Warningf("EC balance job %s (%s): failed to send progress (%.0f%%, stage=%q): %v, cancelling execution",
|
||||
request.Job.JobId, request.Job.JobType, progress, stage, err)
|
||||
execCancel()
|
||||
}
|
||||
})
|
||||
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: "ec balance job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("assigned", "ec balance job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := task.Execute(execCtx, params); err != nil {
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_FAILED,
|
||||
ProgressPercent: 100,
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
sourceNode := params.Sources[0].Node
|
||||
targetNode := params.Targets[0].Node
|
||||
resultSummary := fmt.Sprintf("EC shard balance completed: volume %d shards moved from %s to %s",
|
||||
params.VolumeId, sourceNode, targetNode)
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
Success: true,
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
|
||||
},
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
metrics, activeTopology, _, err := pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveECBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *ecBalanceWorkerConfig {
|
||||
taskConfig := NewDefaultConfig()
|
||||
|
||||
imbalanceThreshold := pluginworker.ReadDoubleConfig(values, "imbalance_threshold", taskConfig.ImbalanceThreshold)
|
||||
if imbalanceThreshold < ecBalanceMinImbalanceThreshold {
|
||||
imbalanceThreshold = ecBalanceMinImbalanceThreshold
|
||||
}
|
||||
if imbalanceThreshold > ecBalanceMaxImbalanceThreshold {
|
||||
imbalanceThreshold = ecBalanceMaxImbalanceThreshold
|
||||
}
|
||||
taskConfig.ImbalanceThreshold = imbalanceThreshold
|
||||
|
||||
minServerCount := pluginworker.ReadIntConfig(values, "min_server_count", taskConfig.MinServerCount)
|
||||
if minServerCount < ecBalanceMinServerCount {
|
||||
minServerCount = ecBalanceMinServerCount
|
||||
}
|
||||
taskConfig.MinServerCount = minServerCount
|
||||
|
||||
ioBytePerSecond := pluginworker.ReadInt64Config(values, "io_byte_per_second", taskConfig.IoBytePerSecond)
|
||||
if ioBytePerSecond < 0 {
|
||||
ioBytePerSecond = 0
|
||||
}
|
||||
taskConfig.IoBytePerSecond = ioBytePerSecond
|
||||
|
||||
taskConfig.PreferredTags = util.NormalizeTagList(pluginworker.ReadStringListConfig(values, "preferred_tags"))
|
||||
|
||||
return &ecBalanceWorkerConfig{
|
||||
TaskConfig: taskConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func buildECBalanceProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.JobProposal, error) {
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("task detection result is nil")
|
||||
}
|
||||
if result.TypedParams == nil {
|
||||
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
|
||||
}
|
||||
|
||||
paramsPayload, err := proto.Marshal(result.TypedParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal task params: %w", err)
|
||||
}
|
||||
|
||||
proposalID := strings.TrimSpace(result.TaskID)
|
||||
if proposalID == "" {
|
||||
proposalID = fmt.Sprintf("ec-balance-%d-%d", result.VolumeID, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Dedupe per (collection, volume), NOT per shard. A volume's EC shards can be
|
||||
// spread across several disks on one server, and concurrent moves of the same
|
||||
// volume race on its shared .ecx/.ecj/.vif sidecar files. Keying by volume
|
||||
// makes the scheduler run only one of a volume's moves at a time — both within
|
||||
// a detection run and against in-flight jobs — and because the planner emits a
|
||||
// volume's moves in phase order (dedup, cross-rack, within-rack, global), the
|
||||
// phases then execute in order across detection cycles. This mirrors the shell,
|
||||
// which serializes a volume's moves and waits between phases.
|
||||
dedupeKey := fmt.Sprintf("ec_balance:%d", result.VolumeID)
|
||||
if result.Collection != "" {
|
||||
dedupeKey += ":" + result.Collection
|
||||
}
|
||||
|
||||
sourceNode := ""
|
||||
targetNode := ""
|
||||
if len(result.TypedParams.Sources) > 0 {
|
||||
sourceNode = strings.TrimSpace(result.TypedParams.Sources[0].Node)
|
||||
}
|
||||
if len(result.TypedParams.Targets) > 0 {
|
||||
targetNode = strings.TrimSpace(result.TypedParams.Targets[0].Node)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Balance EC shard of volume %d", result.VolumeID)
|
||||
if sourceNode != "" && targetNode != "" {
|
||||
summary = fmt.Sprintf("Move EC shard of volume %d: %s → %s", result.VolumeID, sourceNode, targetNode)
|
||||
}
|
||||
|
||||
// EC shard moves only relocate one shard (1/14 of the volume). Budget
|
||||
// 5 min/GB of full volume size, which conservatively covers the shard
|
||||
// transfer plus mount/registration overhead.
|
||||
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
|
||||
estimatedRuntimeSeconds := volumeSizeGB * 5 * 60
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: proposalID,
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: "ec_balance",
|
||||
Priority: pluginworker.MapTaskPriority(result.Priority),
|
||||
Summary: summary,
|
||||
Detail: strings.TrimSpace(result.Reason),
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {
|
||||
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
|
||||
},
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
|
||||
},
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
|
||||
},
|
||||
"estimated_runtime_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "ec_balance",
|
||||
"volume_id": fmt.Sprintf("%d", result.VolumeID),
|
||||
"collection": result.Collection,
|
||||
"source_node": sourceNode,
|
||||
"target_node": targetNode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeECBalanceTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
|
||||
if job == nil {
|
||||
return nil, fmt.Errorf("job spec is nil")
|
||||
}
|
||||
|
||||
// Try protobuf-encoded params first (preferred path)
|
||||
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err != nil {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: unmarshal task_params_pb: %w", err)
|
||||
}
|
||||
if params.TaskId == "" {
|
||||
params.TaskId = job.JobId
|
||||
}
|
||||
// Validate execution-critical fields in the deserialized TaskParams
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].Node")
|
||||
}
|
||||
if len(params.Sources[0].ShardIds) == 0 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].ShardIds")
|
||||
}
|
||||
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].Node")
|
||||
}
|
||||
if len(params.Targets[0].ShardIds) == 0 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].ShardIds")
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// Legacy fallback: construct TaskParams from individual scalar parameters.
|
||||
// All execution-critical fields are required.
|
||||
volumeID := pluginworker.ReadInt64Config(job.Parameters, "volume_id", 0)
|
||||
sourceNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", ""))
|
||||
targetNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "target_server", ""))
|
||||
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
|
||||
|
||||
if volumeID <= 0 || volumeID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid or missing volume_id: %d", volumeID)
|
||||
}
|
||||
if sourceNode == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing source_server")
|
||||
}
|
||||
if targetNode == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing target_server")
|
||||
}
|
||||
|
||||
shardIDVal, hasShardID := job.Parameters["shard_id"]
|
||||
if !hasShardID || shardIDVal == nil {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing shard_id (required for EcBalanceTaskParams)")
|
||||
}
|
||||
shardID := pluginworker.ReadInt64Config(job.Parameters, "shard_id", -1)
|
||||
if shardID < 0 || shardID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid shard_id: %d", shardID)
|
||||
}
|
||||
|
||||
sourceDiskID := pluginworker.ReadInt64Config(job.Parameters, "source_disk_id", 0)
|
||||
if sourceDiskID < 0 || sourceDiskID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid source_disk_id: %d", sourceDiskID)
|
||||
}
|
||||
targetDiskID := pluginworker.ReadInt64Config(job.Parameters, "target_disk_id", 0)
|
||||
if targetDiskID < 0 || targetDiskID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid target_disk_id: %d", targetDiskID)
|
||||
}
|
||||
ioBytePerSecond := pluginworker.ReadInt64Config(job.Parameters, "io_byte_per_second", 0)
|
||||
|
||||
return &worker_pb.TaskParams{
|
||||
TaskId: job.JobId,
|
||||
VolumeId: uint32(volumeID),
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: sourceNode,
|
||||
DiskId: uint32(sourceDiskID),
|
||||
ShardIds: []uint32{uint32(shardID)},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: targetNode,
|
||||
DiskId: uint32(targetDiskID),
|
||||
ShardIds: []uint32{uint32(shardID)},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
TimeoutSeconds: 600,
|
||||
IoBytePerSecond: ioBytePerSecond,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func emitECBalanceDecisionTrace(
|
||||
sender pluginworker.DetectionSender,
|
||||
taskConfig *Config,
|
||||
results []*workertypes.TaskDetectionResult,
|
||||
maxResults int,
|
||||
hasMore bool,
|
||||
) error {
|
||||
if sender == nil || taskConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count moves by phase
|
||||
phaseCounts := make(map[string]int)
|
||||
for _, result := range results {
|
||||
if result.Reason != "" {
|
||||
// Extract phase from reason string
|
||||
for _, phase := range []string{"dedup", "cross_rack", "within_rack", "global"} {
|
||||
if strings.Contains(result.Reason, phase) {
|
||||
phaseCounts[phase]++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summarySuffix := ""
|
||||
if hasMore {
|
||||
summarySuffix = fmt.Sprintf(" (max_results=%d reached)", maxResults)
|
||||
}
|
||||
|
||||
summaryMessage := fmt.Sprintf(
|
||||
"EC balance detection: %d moves proposed%s (dedup=%d, cross_rack=%d, within_rack=%d, global=%d)",
|
||||
len(results),
|
||||
summarySuffix,
|
||||
phaseCounts["dedup"],
|
||||
phaseCounts["cross_rack"],
|
||||
phaseCounts["within_rack"],
|
||||
phaseCounts["global"],
|
||||
)
|
||||
|
||||
return sender.SendActivity(pluginworker.BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
|
||||
},
|
||||
"has_more": {
|
||||
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
|
||||
},
|
||||
"dedup_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["dedup"])},
|
||||
},
|
||||
"cross_rack_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["cross_rack"])},
|
||||
},
|
||||
"within_rack_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["within_rack"])},
|
||||
},
|
||||
"global_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["global"])},
|
||||
},
|
||||
"imbalance_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.ImbalanceThreshold},
|
||||
},
|
||||
"min_server_count": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinServerCount)},
|
||||
},
|
||||
}))
|
||||
}
|
||||
327
weed/worker/tasks/ec_balance/plugin_handler_test.go
Normal file
327
weed/worker/tasks/ec_balance/plugin_handler_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDeriveECBalanceWorkerConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]*plugin_pb.ConfigValue
|
||||
check func(t *testing.T, config *ecBalanceWorkerConfig)
|
||||
}{
|
||||
{
|
||||
name: "nil values uses defaults",
|
||||
values: nil,
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.2 {
|
||||
t.Errorf("expected default threshold 0.2, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if config.TaskConfig.MinServerCount != 3 {
|
||||
t.Errorf("expected default min_server_count 3, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom threshold",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 5}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.3 {
|
||||
t.Errorf("expected threshold 0.3, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if config.TaskConfig.MinServerCount != 5 {
|
||||
t.Errorf("expected min_server_count 5, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold clamped to min",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.01}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.05 {
|
||||
t.Errorf("expected clamped threshold 0.05, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold clamped to max",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.9}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.5 {
|
||||
t.Errorf("expected clamped threshold 0.5, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "min_server_count clamped to 2",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.MinServerCount != 2 {
|
||||
t.Errorf("expected min_server_count 2, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := deriveECBalanceWorkerConfig(tt.values)
|
||||
tt.check(t, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildECBalanceProposal(t *testing.T) {
|
||||
result := &workertypes.TaskDetectionResult{
|
||||
TaskID: "test-task-123",
|
||||
TaskType: workertypes.TaskTypeECBalance,
|
||||
VolumeID: 42,
|
||||
Collection: "test-col",
|
||||
Priority: workertypes.TaskPriorityMedium,
|
||||
Reason: "cross_rack balance",
|
||||
TypedParams: &worker_pb.TaskParams{
|
||||
VolumeId: 42,
|
||||
Collection: "test-col",
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: "source:8080",
|
||||
ShardIds: []uint32{5},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: "target:8080",
|
||||
ShardIds: []uint32{5},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
proposal, err := buildECBalanceProposal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if proposal.JobType != "ec_balance" {
|
||||
t.Errorf("expected job type ec_balance, got %s", proposal.JobType)
|
||||
}
|
||||
if proposal.ProposalId != "test-task-123" {
|
||||
t.Errorf("expected proposal ID test-task-123, got %s", proposal.ProposalId)
|
||||
}
|
||||
// Dedupe is per (volume, collection) so a volume's shard moves serialize and
|
||||
// run in phase order, rather than per shard which allowed concurrent same-volume
|
||||
// moves that race on the volume's .ecx/.ecj/.vif sidecars.
|
||||
if proposal.DedupeKey != "ec_balance:42:test-col" {
|
||||
t.Errorf("expected dedupe key ec_balance:42:test-col, got %s", proposal.DedupeKey)
|
||||
}
|
||||
if proposal.Labels["source_node"] != "source:8080" {
|
||||
t.Errorf("expected source_node label source:8080, got %s", proposal.Labels["source_node"])
|
||||
}
|
||||
if proposal.Labels["target_node"] != "target:8080" {
|
||||
t.Errorf("expected target_node label target:8080, got %s", proposal.Labels["target_node"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildECBalanceProposalNilResult(t *testing.T) {
|
||||
_, err := buildECBalanceProposal(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsFromProtobuf(t *testing.T) {
|
||||
originalParams := &worker_pb.TaskParams{
|
||||
TaskId: "test-id",
|
||||
VolumeId: 100,
|
||||
Collection: "test-col",
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: "source:8080",
|
||||
ShardIds: []uint32{3},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: "target:8080",
|
||||
ShardIds: []uint32{3},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
DiskType: "hdd",
|
||||
TimeoutSeconds: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := proto.Marshal(originalParams)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-1",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
decoded, err := decodeECBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
|
||||
if decoded.VolumeId != 100 {
|
||||
t.Errorf("expected volume_id 100, got %d", decoded.VolumeId)
|
||||
}
|
||||
if decoded.Collection != "test-col" {
|
||||
t.Errorf("expected collection test-col, got %s", decoded.Collection)
|
||||
}
|
||||
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "source:8080" {
|
||||
t.Error("source mismatch")
|
||||
}
|
||||
if len(decoded.Targets) != 1 || decoded.Targets[0].Node != "target:8080" {
|
||||
t.Error("target mismatch")
|
||||
}
|
||||
ecParams := decoded.GetEcBalanceParams()
|
||||
if ecParams == nil {
|
||||
t.Fatal("expected ec_balance_params")
|
||||
}
|
||||
if ecParams.DiskType != "hdd" {
|
||||
t.Errorf("expected disk_type hdd, got %s", ecParams.DiskType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsFallback(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-2",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 200}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "fallback-col"}},
|
||||
"shard_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
|
||||
"source_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
"target_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
}
|
||||
|
||||
decoded, err := decodeECBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode fallback: %v", err)
|
||||
}
|
||||
|
||||
if decoded.VolumeId != 200 {
|
||||
t.Errorf("expected volume_id 200, got %d", decoded.VolumeId)
|
||||
}
|
||||
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "src:8080" {
|
||||
t.Error("source mismatch")
|
||||
}
|
||||
if decoded.Sources[0].ShardIds[0] != 7 {
|
||||
t.Errorf("expected shard_id 7, got %d", decoded.Sources[0].ShardIds[0])
|
||||
}
|
||||
if decoded.Sources[0].DiskId != 1 {
|
||||
t.Errorf("expected source disk_id 1, got %d", decoded.Sources[0].DiskId)
|
||||
}
|
||||
if decoded.Targets[0].DiskId != 2 {
|
||||
t.Errorf("expected target disk_id 2, got %d", decoded.Targets[0].DiskId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsProtobufValidation(t *testing.T) {
|
||||
// Protobuf payload with missing ShardIds should fail validation
|
||||
badParams := &worker_pb.TaskParams{
|
||||
TaskId: "test-id",
|
||||
VolumeId: 100,
|
||||
Sources: []*worker_pb.TaskSource{{Node: "source:8080"}}, // no ShardIds
|
||||
Targets: []*worker_pb.TaskTarget{{Node: "target:8080", ShardIds: []uint32{3}}},
|
||||
}
|
||||
payload, _ := proto.Marshal(badParams)
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-validate",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
_, err := decodeECBalanceTaskParams(job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing Sources[0].ShardIds in protobuf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsMissingShardID(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-3",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 300}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "test-col"}},
|
||||
// shard_id deliberately missing
|
||||
},
|
||||
}
|
||||
|
||||
_, err := decodeECBalanceTaskParams(job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing shard_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBalanceHandlerCapability(t *testing.T) {
|
||||
handler := NewECBalanceHandler(nil)
|
||||
cap := handler.Capability()
|
||||
|
||||
if cap.JobType != "ec_balance" {
|
||||
t.Errorf("expected job type ec_balance, got %s", cap.JobType)
|
||||
}
|
||||
if !cap.CanDetect {
|
||||
t.Error("expected CanDetect=true")
|
||||
}
|
||||
if !cap.CanExecute {
|
||||
t.Error("expected CanExecute=true")
|
||||
}
|
||||
if cap.Weight != 60 {
|
||||
t.Errorf("expected weight 60, got %d", cap.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBalanceConfigRoundTrip(t *testing.T) {
|
||||
config := NewDefaultConfig()
|
||||
config.ImbalanceThreshold = 0.3
|
||||
config.MinServerCount = 5
|
||||
config.CollectionFilter = "my_col"
|
||||
config.DiskType = "ssd"
|
||||
config.PreferredTags = []string{"fast", "ssd"}
|
||||
|
||||
policy := config.ToTaskPolicy()
|
||||
if policy == nil {
|
||||
t.Fatal("expected non-nil policy")
|
||||
}
|
||||
|
||||
config2 := NewDefaultConfig()
|
||||
if err := config2.FromTaskPolicy(policy); err != nil {
|
||||
t.Fatalf("failed to load from policy: %v", err)
|
||||
}
|
||||
|
||||
if config2.ImbalanceThreshold != 0.3 {
|
||||
t.Errorf("expected threshold 0.3, got %f", config2.ImbalanceThreshold)
|
||||
}
|
||||
if config2.MinServerCount != 5 {
|
||||
t.Errorf("expected min_server_count 5, got %d", config2.MinServerCount)
|
||||
}
|
||||
if config2.CollectionFilter != "my_col" {
|
||||
t.Errorf("expected collection_filter my_col, got %s", config2.CollectionFilter)
|
||||
}
|
||||
if config2.DiskType != "ssd" {
|
||||
t.Errorf("expected disk_type ssd, got %s", config2.DiskType)
|
||||
}
|
||||
if len(config2.PreferredTags) != 2 || config2.PreferredTags[0] != "fast" {
|
||||
t.Errorf("expected preferred_tags [fast ssd], got %v", config2.PreferredTags)
|
||||
}
|
||||
}
|
||||
89
weed/worker/tasks/ec_balance/register.go
Normal file
89
weed/worker/tasks/ec_balance/register.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Global variable to hold the task definition for configuration updates
|
||||
var globalTaskDef *base.TaskDefinition
|
||||
|
||||
// Auto-register this task when the package is imported
|
||||
func init() {
|
||||
RegisterECBalanceTask()
|
||||
|
||||
// Register config updater
|
||||
tasks.AutoRegisterConfigUpdater(types.TaskTypeECBalance, UpdateConfigFromPersistence)
|
||||
}
|
||||
|
||||
// RegisterECBalanceTask registers the EC balance task with the task architecture
|
||||
func RegisterECBalanceTask() {
|
||||
cfg := NewDefaultConfig()
|
||||
|
||||
// Create shared gRPC dial option using TLS configuration
|
||||
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
|
||||
|
||||
// Create complete task definition
|
||||
taskDef := &base.TaskDefinition{
|
||||
Type: types.TaskTypeECBalance,
|
||||
Name: "ec_balance",
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Balances EC shard distribution across racks and servers",
|
||||
Icon: "fas fa-balance-scale-left text-info",
|
||||
Capabilities: []string{"ec_balance", "erasure_coding"},
|
||||
|
||||
Config: cfg,
|
||||
ConfigSpec: GetConfigSpec(),
|
||||
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
|
||||
if params == nil {
|
||||
return nil, fmt.Errorf("task parameters are required")
|
||||
}
|
||||
return NewECBalanceTask(
|
||||
fmt.Sprintf("ec_balance-%d", params.VolumeId),
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
dialOpt,
|
||||
), nil
|
||||
},
|
||||
DetectionFunc: func(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
|
||||
results, _, err := Detection(context.Background(), metrics, clusterInfo, config, 0)
|
||||
return results, err
|
||||
},
|
||||
ScanInterval: 1 * time.Hour,
|
||||
SchedulingFunc: Scheduling,
|
||||
MaxConcurrent: 1,
|
||||
RepeatInterval: 4 * time.Hour,
|
||||
}
|
||||
|
||||
// Store task definition globally for configuration updates
|
||||
globalTaskDef = taskDef
|
||||
|
||||
// Register everything with a single function call
|
||||
base.RegisterTask(taskDef)
|
||||
}
|
||||
|
||||
// UpdateConfigFromPersistence updates the EC balance configuration from persistence
|
||||
func UpdateConfigFromPersistence(configPersistence interface{}) error {
|
||||
if globalTaskDef == nil {
|
||||
return fmt.Errorf("ec balance task not registered")
|
||||
}
|
||||
|
||||
newConfig := LoadConfigFromPersistence(configPersistence)
|
||||
if newConfig == nil {
|
||||
return fmt.Errorf("failed to load configuration from persistence")
|
||||
}
|
||||
|
||||
globalTaskDef.Config = newConfig
|
||||
|
||||
glog.V(1).Infof("Updated EC balance task configuration from persistence")
|
||||
return nil
|
||||
}
|
||||
40
weed/worker/tasks/ec_balance/scheduling.go
Normal file
40
weed/worker/tasks/ec_balance/scheduling.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package ec_balance
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Scheduling implements the scheduling logic for EC balance tasks
|
||||
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
|
||||
ecbConfig := config.(*Config)
|
||||
|
||||
// Check if we have available workers
|
||||
if len(availableWorkers) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Count running EC balance tasks
|
||||
runningCount := 0
|
||||
for _, runningTask := range runningTasks {
|
||||
if runningTask.Type == types.TaskTypeECBalance {
|
||||
runningCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrency limit
|
||||
if runningCount >= ecbConfig.MaxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any worker can handle EC balance tasks
|
||||
for _, worker := range availableWorkers {
|
||||
for _, capability := range worker.Capabilities {
|
||||
if capability == types.TaskTypeECBalance {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
255
weed/worker/tasks/erasure_coding/config.go
Normal file
255
weed/worker/tasks/erasure_coding/config.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
)
|
||||
|
||||
// Config extends BaseConfig with erasure coding specific settings
|
||||
type Config struct {
|
||||
base.BaseConfig
|
||||
QuietForSeconds int `json:"quiet_for_seconds"`
|
||||
FullnessRatio float64 `json:"fullness_ratio"`
|
||||
CollectionFilter string `json:"collection_filter"`
|
||||
MinSizeMB int `json:"min_size_mb"`
|
||||
PreferredTags []string `json:"preferred_tags"`
|
||||
ReplicaPlacement string `json:"replica_placement"` // e.g. "020"; empty falls back to the master default replication
|
||||
}
|
||||
|
||||
// NewDefaultConfig creates a new default erasure coding configuration
|
||||
func NewDefaultConfig() *Config {
|
||||
return &Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: true,
|
||||
ScanIntervalSeconds: 60 * 60, // 1 hour
|
||||
MaxConcurrent: 1,
|
||||
},
|
||||
QuietForSeconds: 3600, // 1 hour, matching the shell ec.encode -quietFor default
|
||||
FullnessRatio: 0.95, // 95%, matching the shell ec.encode -fullPercent default
|
||||
CollectionFilter: "",
|
||||
MinSizeMB: 30, // 30MB (more reasonable than 100MB)
|
||||
PreferredTags: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec returns the configuration schema for erasure coding tasks
|
||||
func GetConfigSpec() base.ConfigSpec {
|
||||
return base.ConfigSpec{
|
||||
Fields: []*config.Field{
|
||||
{
|
||||
Name: "enabled",
|
||||
JSONName: "enabled",
|
||||
Type: config.FieldTypeBool,
|
||||
DefaultValue: true,
|
||||
Required: false,
|
||||
DisplayName: "Enable Erasure Coding Tasks",
|
||||
Description: "Whether erasure coding tasks should be automatically created",
|
||||
HelpText: "Toggle this to enable or disable automatic erasure coding task generation",
|
||||
InputType: "checkbox",
|
||||
CSSClasses: "form-check-input",
|
||||
},
|
||||
{
|
||||
Name: "scan_interval_seconds",
|
||||
JSONName: "scan_interval_seconds",
|
||||
Type: config.FieldTypeInterval,
|
||||
DefaultValue: 60 * 60,
|
||||
MinValue: 10 * 60,
|
||||
MaxValue: 24 * 60 * 60,
|
||||
Required: true,
|
||||
DisplayName: "Scan Interval",
|
||||
Description: "How often to scan for volumes needing erasure coding",
|
||||
HelpText: "The system will check for volumes that need erasure coding at this interval",
|
||||
Placeholder: "1",
|
||||
Unit: config.UnitHours,
|
||||
InputType: "interval",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "max_concurrent",
|
||||
JSONName: "max_concurrent",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 1,
|
||||
MinValue: 1,
|
||||
MaxValue: 5,
|
||||
Required: true,
|
||||
DisplayName: "Max Concurrent Tasks",
|
||||
Description: "Maximum number of erasure coding tasks that can run simultaneously",
|
||||
HelpText: "Limits the number of erasure coding operations running at the same time",
|
||||
Placeholder: "1 (default)",
|
||||
Unit: config.UnitCount,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "quiet_for_seconds",
|
||||
JSONName: "quiet_for_seconds",
|
||||
Type: config.FieldTypeInterval,
|
||||
DefaultValue: 3600,
|
||||
MinValue: 1,
|
||||
MaxValue: 3600,
|
||||
Required: true,
|
||||
DisplayName: "Quiet Period",
|
||||
Description: "Minimum time volume must be quiet before erasure coding",
|
||||
HelpText: "Volume must not be modified for this duration before erasure coding",
|
||||
Placeholder: "60",
|
||||
Unit: config.UnitMinutes,
|
||||
InputType: "interval",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "fullness_ratio",
|
||||
JSONName: "fullness_ratio",
|
||||
Type: config.FieldTypeFloat,
|
||||
DefaultValue: 0.95,
|
||||
MinValue: 0.0001,
|
||||
MaxValue: 1.0,
|
||||
Required: true,
|
||||
DisplayName: "Fullness Ratio",
|
||||
Description: "Minimum fullness ratio to trigger erasure coding",
|
||||
HelpText: "Only volumes with this fullness ratio or higher will be erasure coded",
|
||||
Placeholder: "0.80 (80%)",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "collection_filter",
|
||||
JSONName: "collection_filter",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Collection Filter",
|
||||
Description: "Only process volumes from specific collections (comma-separated names, wildcards, or regex patterns)",
|
||||
HelpText: "Leave empty to process all collections, or list the collections to process",
|
||||
Placeholder: "pictures,videos",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "min_size_mb",
|
||||
JSONName: "min_size_mb",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 30,
|
||||
MinValue: 1,
|
||||
MaxValue: 1000,
|
||||
Required: true,
|
||||
DisplayName: "Minimum Size (MB)",
|
||||
Description: "Minimum volume size to consider for erasure coding",
|
||||
HelpText: "Only volumes larger than this size will be considered for erasure coding",
|
||||
Placeholder: "30",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "preferred_tags",
|
||||
JSONName: "preferred_tags",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Preferred Disk Tags",
|
||||
Description: "Comma-separated disk tags to prioritize for EC shard placement",
|
||||
HelpText: "EC shards will be placed on disks with these tags first, then fall back to other disks if needed",
|
||||
Placeholder: "fast,ssd",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "replica_placement",
|
||||
JSONName: "replica_placement",
|
||||
Type: config.FieldTypeString,
|
||||
DefaultValue: "",
|
||||
Required: false,
|
||||
DisplayName: "Replica Placement",
|
||||
Description: "EC shard replica placement constraint (e.g. 020)",
|
||||
HelpText: "Leave empty to use the master default replication. When set, the 2nd/3rd digits cap EC shards per rack and per node (best-effort during encode: relaxed rather than failing if the cluster can't satisfy them, then enforced by rebalancing). The 1st (data-center) digit is ignored for EC placement",
|
||||
Placeholder: "020",
|
||||
InputType: "text",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
|
||||
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
|
||||
// Defensive copy of PreferredTags to prevent external mutation
|
||||
preferredTagsCopy := append([]string(nil), c.PreferredTags...)
|
||||
return &worker_pb.TaskPolicy{
|
||||
Enabled: c.Enabled,
|
||||
MaxConcurrent: int32(c.MaxConcurrent),
|
||||
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
TaskConfig: &worker_pb.TaskPolicy_ErasureCodingConfig{
|
||||
ErasureCodingConfig: &worker_pb.ErasureCodingTaskConfig{
|
||||
FullnessRatio: float64(c.FullnessRatio),
|
||||
QuietForSeconds: int32(c.QuietForSeconds),
|
||||
MinVolumeSizeMb: int32(c.MinSizeMB),
|
||||
CollectionFilter: c.CollectionFilter,
|
||||
PreferredTags: preferredTagsCopy,
|
||||
ReplicaPlacement: c.ReplicaPlacement,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
|
||||
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy is nil")
|
||||
}
|
||||
|
||||
// Set general TaskPolicy fields
|
||||
c.Enabled = policy.Enabled
|
||||
c.MaxConcurrent = int(policy.MaxConcurrent)
|
||||
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds) // Direct seconds-to-seconds mapping
|
||||
|
||||
// Set erasure coding-specific fields from the task config
|
||||
if ecConfig := policy.GetErasureCodingConfig(); ecConfig != nil {
|
||||
c.FullnessRatio = float64(ecConfig.FullnessRatio)
|
||||
c.QuietForSeconds = int(ecConfig.QuietForSeconds)
|
||||
c.MinSizeMB = int(ecConfig.MinVolumeSizeMb)
|
||||
c.CollectionFilter = ecConfig.CollectionFilter
|
||||
c.PreferredTags = append([]string(nil), ecConfig.PreferredTags...)
|
||||
c.ReplicaPlacement = ecConfig.ReplicaPlacement
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigFromPersistence loads configuration from the persistence layer if available
|
||||
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
|
||||
config := NewDefaultConfig()
|
||||
|
||||
// Try to load from persistence if available
|
||||
if persistence, ok := configPersistence.(interface {
|
||||
LoadErasureCodingTaskPolicy() (*worker_pb.TaskPolicy, error)
|
||||
}); ok {
|
||||
policy, err := persistence.LoadErasureCodingTaskPolicy()
|
||||
switch {
|
||||
case err != nil:
|
||||
glog.Warningf("Could not read the persisted erasure coding configuration, falling back to defaults: %v", err)
|
||||
case policy == nil:
|
||||
glog.V(1).Infof("No erasure coding configuration persisted yet, using defaults")
|
||||
default:
|
||||
if err := config.FromTaskPolicy(policy); err != nil {
|
||||
glog.Warningf("Could not apply the persisted erasure coding configuration, falling back to defaults: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("Loaded erasure coding configuration from persistence")
|
||||
return config
|
||||
}
|
||||
}
|
||||
} else if configPersistence != nil {
|
||||
// A store was handed in but does not expose the accessor, so the persisted
|
||||
// settings are silently ignored - always a wiring bug, never a normal state.
|
||||
glog.Warningf("%T cannot provide the persisted erasure coding configuration: it has no LoadErasureCodingTaskPolicy() method, "+
|
||||
"so the compiled-in defaults are used and any saved erasure coding settings are ignored", configPersistence)
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Using default erasure coding configuration")
|
||||
return config
|
||||
}
|
||||
772
weed/worker/tasks/erasure_coding/detection.go
Normal file
772
weed/worker/tasks/erasure_coding/detection.go
Normal file
@@ -0,0 +1,772 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/ec"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
workerutil "github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
const (
|
||||
minProposalsBeforeEarlyStop = 10
|
||||
maxConsecutivePlanningFailures = 10
|
||||
)
|
||||
|
||||
// Detection implements the detection logic for erasure coding tasks.
|
||||
// It respects ctx cancellation and can stop early once maxResults is reached.
|
||||
func Detection(ctx context.Context, metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig, maxResults int) ([]*types.TaskDetectionResult, bool, error) {
|
||||
if !config.IsEnabled() {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if maxResults < 0 {
|
||||
maxResults = 0
|
||||
}
|
||||
|
||||
ecConfig := config.(*Config)
|
||||
var results []*types.TaskDetectionResult
|
||||
hasMore := false
|
||||
stoppedEarly := false
|
||||
now := time.Now()
|
||||
quietThreshold := time.Duration(ecConfig.QuietForSeconds) * time.Second
|
||||
minSizeBytes := uint64(ecConfig.MinSizeMB) * 1024 * 1024 // Configurable minimum
|
||||
|
||||
debugCount := 0
|
||||
skippedAlreadyEC := 0
|
||||
skippedTooSmall := 0
|
||||
skippedCollectionFilter := 0
|
||||
skippedQuietTime := 0
|
||||
skippedFullness := 0
|
||||
skippedRemote := 0
|
||||
skippedTooFewNodes := 0
|
||||
consecutivePlanningFailures := 0
|
||||
|
||||
// EC shard replica placement: explicit config wins, else the master default.
|
||||
var replicaPlacement *super_block.ReplicaPlacement
|
||||
if clusterInfo != nil {
|
||||
replicaPlacement = super_block.ResolveReplicaPlacement(ecConfig.ReplicaPlacement, clusterInfo.DefaultReplicaPlacement)
|
||||
}
|
||||
// EC placement honors only the rack/node digits; the data-center digit can't
|
||||
// express a useful per-DC EC shard cap (it maxes at 2). Warn once per cycle so a
|
||||
// 1xx/2xx setting isn't silently ineffective.
|
||||
if replicaPlacement != nil && replicaPlacement.DiffDataCenterCount > 0 {
|
||||
glog.Warningf("EC Detection: replica placement data-center digit (%d) is ignored for EC; only rack/node digits are honored", replicaPlacement.DiffDataCenterCount)
|
||||
}
|
||||
|
||||
allowedCollections, err := wildcard.CompileCollectionMatcher(ecConfig.CollectionFilter)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Cluster node count for the min-node safety gate (mirrors the shell ec.encode
|
||||
// guard that refuses to encode when nodes < parity shards, so shards cannot be
|
||||
// spread for fault tolerance).
|
||||
clusterNodeCount := countTopologyNodes(clusterInfo.ActiveTopology)
|
||||
|
||||
// Group metrics by VolumeID to handle replicas and select canonical server
|
||||
volumeGroups := make(map[uint32][]*types.VolumeHealthMetrics)
|
||||
for _, metric := range metrics {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return results, hasMore, err
|
||||
}
|
||||
}
|
||||
volumeGroups[metric.VolumeID] = append(volumeGroups[metric.VolumeID], metric)
|
||||
}
|
||||
|
||||
groupKeys := make([]uint32, 0, len(volumeGroups))
|
||||
for volumeID := range volumeGroups {
|
||||
groupKeys = append(groupKeys, volumeID)
|
||||
}
|
||||
sort.Slice(groupKeys, func(i, j int) bool { return groupKeys[i] < groupKeys[j] })
|
||||
|
||||
// Build the EC placement snapshot once per detection cycle. planECDestinations
|
||||
// reserves the shards it assigns directly into it, so volumes planned later in
|
||||
// this cycle see the reduced capacity. Rebuilding it per volume re-walked the
|
||||
// whole topology (and ActiveTopology's growing pending-task set) every time,
|
||||
// which is O(volumes × topology) and times out on large clusters. The node
|
||||
// address map is precomputed for the same reason (ResolveServerAddress rebuilds
|
||||
// the full node map on every call).
|
||||
var ecSnapshot *ecbalancer.Topology
|
||||
var nodeAddresses map[string]string
|
||||
if clusterInfo != nil && clusterInfo.ActiveTopology != nil {
|
||||
ecSnapshot = ecbalancer.FromActiveTopology(clusterInfo.ActiveTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses = buildNodeAddressMap(clusterInfo.ActiveTopology)
|
||||
}
|
||||
|
||||
// Iterate over groups to check criteria and creation tasks
|
||||
for idx, volumeID := range groupKeys {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return results, hasMore, err
|
||||
}
|
||||
}
|
||||
if maxResults > 0 && len(results) >= maxResults {
|
||||
if idx+1 < len(groupKeys) {
|
||||
hasMore = true
|
||||
}
|
||||
stoppedEarly = true
|
||||
break
|
||||
}
|
||||
|
||||
groupMetrics := volumeGroups[volumeID]
|
||||
|
||||
// Prefer the lowest-server credible replica so a 0-byte stub or a
|
||||
// leftover EC shard set on a lower server can't become canonical and
|
||||
// strand the volume in skippedTooSmall / skippedAlreadyEC.
|
||||
metric := selectCanonicalMetric(groupMetrics)
|
||||
|
||||
// Skip if already EC volume
|
||||
if metric.IsECVolume {
|
||||
skippedAlreadyEC++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle the "stuck source" state from #9448: a previous encode
|
||||
// succeeded but the post-encode source-delete left a regular replica
|
||||
// behind, so the master heartbeats BOTH the replica AND its EC shards.
|
||||
// metric.IsECVolume above is set only for the EC-side metric path, so
|
||||
// the canonical metric we picked is the regular replica with
|
||||
// IsECVolume=false. Re-proposing an encode in that state collides with
|
||||
// the mounted shards on the targets ("ec volume %d is mounted; refusing
|
||||
// overwrite") and the detector re-queues forever.
|
||||
//
|
||||
// We only act when the EC shard set is COMPLETE — fewer than
|
||||
// totalShards present means the existing recovery branch below
|
||||
// (around the `existingECShards` block) should keep its chance to
|
||||
// fold the partial shards into the new task. Counting walks
|
||||
// EcIndexBits to handle a single info entry carrying multiple shards.
|
||||
if clusterInfo.ActiveTopology != nil {
|
||||
shardCount := countExistingEcShardsForVolume(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
|
||||
totalShards := erasure_coding.DataShardsCount + erasure_coding.ParityShardsCount
|
||||
if shardCount >= totalShards {
|
||||
glog.Warningf("EC Detection: Volume %d has all %d EC shards in topology; "+
|
||||
"source replica on %s is orphaned (#9448).",
|
||||
metric.VolumeID, totalShards, metric.Server)
|
||||
if clusterInfo.GrpcDialOption != nil {
|
||||
deleted, cleanupErr := cleanupOrphanSourceReplicas(ctx, clusterInfo, metric, totalShards)
|
||||
switch {
|
||||
case cleanupErr != nil:
|
||||
// Don't fall through to a re-encode — that would just
|
||||
// collide with the mounted shards again. Surface the
|
||||
// failure and wait for the next cycle; the source is
|
||||
// still safe.
|
||||
glog.Warningf("EC Detection: failed to auto-clean orphaned source for volume %d: %v", metric.VolumeID, cleanupErr)
|
||||
case deleted > 0:
|
||||
glog.Infof("EC Detection: auto-cleaned %d orphaned source replica(s) for volume %d after verifying all %d EC shards present", deleted, metric.VolumeID, totalShards)
|
||||
default:
|
||||
glog.V(1).Infof("EC Detection: no orphaned regular replicas found in topology for volume %d (collection %q)", metric.VolumeID, metric.Collection)
|
||||
}
|
||||
} else {
|
||||
glog.Warningf("EC Detection: no gRPC dial option available to auto-clean orphaned source for volume %d; "+
|
||||
"to clean up by hand, send a targeted VolumeDelete RPC to %s only — DO NOT use the cluster-wide `volume.delete` shell command, which would also delete the EC shards.",
|
||||
metric.VolumeID, metric.Server)
|
||||
}
|
||||
skippedAlreadyEC++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check minimum size requirement
|
||||
if metric.Size < minSizeBytes {
|
||||
skippedTooSmall++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check collection filter if specified
|
||||
if !allowedCollections.Matches(metric.Collection) {
|
||||
skippedCollectionFilter++
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip remote/tiered volumes: encoding them would lose the tiering. The
|
||||
// shell ec.encode excludes remote volumes for the same reason.
|
||||
if metric.HasRemoteCopy {
|
||||
skippedRemote++
|
||||
continue
|
||||
}
|
||||
|
||||
// Min-node safety gate: don't encode when the cluster has fewer nodes than
|
||||
// this collection's parity shards — shards could not be spread to tolerate
|
||||
// failures. Mirrors the shell ec.encode guard (node count < parity shards).
|
||||
if clusterNodeCount > 0 && clusterNodeCount < erasure_coding.ParityShardsCount {
|
||||
skippedTooFewNodes++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check quiet duration and fullness criteria
|
||||
if metric.Age >= quietThreshold && metric.FullnessRatio >= ecConfig.FullnessRatio {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return results, hasMore, err
|
||||
}
|
||||
}
|
||||
glog.Infof("EC Detection: Volume %d meets all criteria, attempting to create task", metric.VolumeID)
|
||||
|
||||
// Generate task ID for ActiveTopology integration
|
||||
taskID := fmt.Sprintf("ec_vol_%d_%d", metric.VolumeID, now.Unix())
|
||||
|
||||
result := &types.TaskDetectionResult{
|
||||
TaskID: taskID, // Link to ActiveTopology pending task
|
||||
TaskType: types.TaskTypeErasureCoding,
|
||||
VolumeID: metric.VolumeID,
|
||||
Server: metric.Server,
|
||||
Collection: metric.Collection,
|
||||
Priority: types.TaskPriorityLow, // EC is not urgent
|
||||
Reason: fmt.Sprintf("Volume meets EC criteria: quiet for %.1fs (>%ds), fullness=%.1f%% (>%.1f%%), size=%.1fMB (>%dMB)",
|
||||
metric.Age.Seconds(), ecConfig.QuietForSeconds, metric.FullnessRatio*100, ecConfig.FullnessRatio*100,
|
||||
float64(metric.Size)/(1024*1024), ecConfig.MinSizeMB),
|
||||
ScheduleAt: now,
|
||||
}
|
||||
|
||||
// Plan EC destinations if ActiveTopology is available
|
||||
if clusterInfo.ActiveTopology != nil {
|
||||
// Check if ANY task already exists in ActiveTopology for this volume
|
||||
if clusterInfo.ActiveTopology.HasAnyTask(metric.VolumeID) {
|
||||
glog.V(2).Infof("EC Detection: Skipping volume %d, task already exists in ActiveTopology", metric.VolumeID)
|
||||
continue
|
||||
}
|
||||
|
||||
glog.Infof("EC Detection: ActiveTopology available, planning destinations for volume %d", metric.VolumeID)
|
||||
dataShards := erasure_coding.DataShardsCount
|
||||
parityShards := erasure_coding.ParityShardsCount
|
||||
multiPlan, shardsPerPlan, err := planECDestinations(ecSnapshot, nodeAddresses, metric, ecConfig, replicaPlacement, dataShards, parityShards)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Failed to plan EC destinations for volume %d: %v", metric.VolumeID, err)
|
||||
consecutivePlanningFailures++
|
||||
if len(results) >= minProposalsBeforeEarlyStop && consecutivePlanningFailures >= maxConsecutivePlanningFailures {
|
||||
glog.Warningf("EC Detection: stopping early after %d consecutive placement failures with %d proposals already planned", consecutivePlanningFailures, len(results))
|
||||
hasMore = true
|
||||
stoppedEarly = true
|
||||
break
|
||||
}
|
||||
continue // Skip this volume if destination planning fails
|
||||
}
|
||||
consecutivePlanningFailures = 0
|
||||
glog.Infof("EC Detection: Successfully planned %d destinations for volume %d", len(multiPlan.Plans), metric.VolumeID)
|
||||
|
||||
// Calculate expected shard size for EC operation
|
||||
// Each data shard will be approximately volumeSize / dataShards
|
||||
expectedShardSize := uint64(metric.Size) / uint64(dataShards)
|
||||
|
||||
// Add pending EC shard task to ActiveTopology for capacity management
|
||||
|
||||
// Extract shard destinations from multiPlan
|
||||
var shardDestinations []string
|
||||
var shardDiskIDs []uint32
|
||||
for _, plan := range multiPlan.Plans {
|
||||
shardDestinations = append(shardDestinations, plan.TargetNode)
|
||||
shardDiskIDs = append(shardDiskIDs, plan.TargetDisk)
|
||||
}
|
||||
|
||||
// Find all volume replica locations (server + disk) from topology
|
||||
glog.Infof("EC Detection: Looking for replica locations for volume %d", metric.VolumeID)
|
||||
replicaLocations := findVolumeReplicaLocations(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
|
||||
if len(replicaLocations) == 0 {
|
||||
glog.Warningf("No replica locations found for volume %d, skipping EC", metric.VolumeID)
|
||||
continue
|
||||
}
|
||||
glog.Infof("EC Detection: Found %d replica locations for volume %d", len(replicaLocations), metric.VolumeID)
|
||||
|
||||
// Find existing EC shards from previous failed attempts
|
||||
existingECShards := findExistingECShards(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
|
||||
|
||||
// Combine volume replicas and existing EC shards for cleanup
|
||||
var sources []topology.TaskSourceSpec
|
||||
|
||||
// Add volume replicas (will free volume slots)
|
||||
for _, replica := range replicaLocations {
|
||||
sources = append(sources, topology.TaskSourceSpec{
|
||||
ServerID: replica.ServerID,
|
||||
DiskID: replica.DiskID,
|
||||
DataCenter: replica.DataCenter,
|
||||
Rack: replica.Rack,
|
||||
CleanupType: topology.CleanupVolumeReplica,
|
||||
})
|
||||
}
|
||||
|
||||
// Add existing EC shards (will free shard slots)
|
||||
duplicateCheck := make(map[string]bool)
|
||||
for _, replica := range replicaLocations {
|
||||
key := fmt.Sprintf("%s:%d", replica.ServerID, replica.DiskID)
|
||||
duplicateCheck[key] = true
|
||||
}
|
||||
|
||||
for _, shard := range existingECShards {
|
||||
key := fmt.Sprintf("%s:%d", shard.ServerID, shard.DiskID)
|
||||
if !duplicateCheck[key] { // Avoid duplicates if EC shards are on same disk as volume replicas
|
||||
shardIds := append([]uint32(nil), shard.ShardIds...)
|
||||
// Free exactly the shards on this disk. Without an explicit
|
||||
// impact the cleanup falls back to CalculateECShardCleanupImpact,
|
||||
// which credits TotalShardsCount (14) regardless of ratio.
|
||||
cleanupImpact := topology.StorageSlotChange{ShardSlots: -int32(len(shardIds))}
|
||||
sources = append(sources, topology.TaskSourceSpec{
|
||||
ServerID: shard.ServerID,
|
||||
DiskID: shard.DiskID,
|
||||
DataCenter: shard.DataCenter,
|
||||
Rack: shard.Rack,
|
||||
CleanupType: topology.CleanupECShards,
|
||||
ShardIds: shardIds,
|
||||
StorageImpact: &cleanupImpact,
|
||||
})
|
||||
duplicateCheck[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Found %d volume replicas and %d existing EC shards for volume %d (total %d cleanup sources)",
|
||||
len(replicaLocations), len(existingECShards), metric.VolumeID, len(sources))
|
||||
|
||||
// Convert shard destinations to TaskDestinationSpec. A destination may
|
||||
// hold several shards (small clusters), so reserve capacity for the
|
||||
// actual per-disk shard count that Place assigned (shardsPerPlan),
|
||||
// which is exactly what createECTargets writes.
|
||||
destinations := make([]topology.TaskDestinationSpec, len(shardDestinations))
|
||||
for i, dest := range shardDestinations {
|
||||
shardCount := len(shardsPerPlan[i])
|
||||
shardImpact := topology.CalculateECShardStorageImpact(int32(shardCount), int64(expectedShardSize))
|
||||
destSize := int64(expectedShardSize) * int64(shardCount)
|
||||
destinations[i] = topology.TaskDestinationSpec{
|
||||
ServerID: dest,
|
||||
DiskID: shardDiskIDs[i],
|
||||
StorageImpact: &shardImpact,
|
||||
EstimatedSize: &destSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert sources before mutating topology
|
||||
sourcesProto, err := convertTaskSourcesToProtobuf(sources, metric.VolumeID, clusterInfo.ActiveTopology)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to convert sources for EC task on volume %d: %v, skipping", metric.VolumeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = clusterInfo.ActiveTopology.AddPendingTask(topology.TaskSpec{
|
||||
TaskID: taskID,
|
||||
TaskType: topology.TaskTypeErasureCoding,
|
||||
VolumeID: metric.VolumeID,
|
||||
VolumeSize: int64(metric.Size),
|
||||
Sources: sources,
|
||||
Destinations: destinations,
|
||||
})
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to add pending EC shard task to ActiveTopology for volume %d: %v", metric.VolumeID, err)
|
||||
continue // Skip this volume if topology task addition fails
|
||||
}
|
||||
|
||||
// Cross-volume in-cycle capacity is tracked by ActiveTopology via the
|
||||
// pending task above, which the next volume's FromActiveTopology snapshot
|
||||
// reflects; no separate planner reservation is needed.
|
||||
|
||||
glog.V(2).Infof("Added pending EC shard task %s to ActiveTopology for volume %d with %d cleanup sources and %d shard destinations",
|
||||
taskID, metric.VolumeID, len(sources), len(multiPlan.Plans))
|
||||
|
||||
// Create unified sources and targets for EC task
|
||||
result.TypedParams = &worker_pb.TaskParams{
|
||||
TaskId: taskID, // Link to ActiveTopology pending task
|
||||
VolumeId: metric.VolumeID,
|
||||
Collection: metric.Collection,
|
||||
VolumeSize: metric.Size, // Store original volume size for tracking changes
|
||||
|
||||
// Unified sources - all sources that will be processed/cleaned up
|
||||
Sources: sourcesProto,
|
||||
|
||||
// Unified targets - all EC shard destinations
|
||||
Targets: createECTargets(multiPlan, shardsPerPlan),
|
||||
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: createECTaskParams(dataShards, parityShards, metric.DiskType),
|
||||
},
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Planned EC destinations for volume %d: %d shards across %d racks, %d DCs",
|
||||
metric.VolumeID, len(multiPlan.Plans), multiPlan.SuccessfulRack, multiPlan.SuccessfulDCs)
|
||||
} else {
|
||||
glog.Warningf("No ActiveTopology available for destination planning in EC detection")
|
||||
continue // Skip this volume if no topology available
|
||||
}
|
||||
|
||||
glog.Infof("EC Detection: Successfully created EC task for volume %d, adding to results", metric.VolumeID)
|
||||
results = append(results, result)
|
||||
} else {
|
||||
// Count debug reasons
|
||||
if metric.Age < quietThreshold {
|
||||
skippedQuietTime++
|
||||
}
|
||||
if metric.FullnessRatio < ecConfig.FullnessRatio {
|
||||
skippedFullness++
|
||||
}
|
||||
|
||||
if debugCount < 5 { // Limit to avoid spam
|
||||
// Logic moved outside
|
||||
}
|
||||
debugCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Log debug summary if no tasks were created
|
||||
if len(results) == 0 && len(metrics) > 0 && !stoppedEarly {
|
||||
totalVolumes := len(metrics)
|
||||
glog.V(1).Infof("EC detection: No tasks created for %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full, %d remote, %d too few nodes)",
|
||||
totalVolumes, skippedAlreadyEC, skippedTooSmall, skippedCollectionFilter, skippedQuietTime, skippedFullness, skippedRemote, skippedTooFewNodes)
|
||||
|
||||
// Show details for first few volumes
|
||||
for i, metric := range metrics {
|
||||
if i >= 3 || metric.IsECVolume { // Limit to first 3 non-EC volumes
|
||||
continue
|
||||
}
|
||||
sizeMB := float64(metric.Size) / (1024 * 1024)
|
||||
glog.V(1).Infof("ERASURE CODING: Volume %d: size=%.1fMB (need ≥%dMB), age=%s (need ≥%s), fullness=%.1f%% (need ≥%.1f%%)",
|
||||
metric.VolumeID, sizeMB, ecConfig.MinSizeMB, metric.Age.Truncate(time.Minute), quietThreshold.Truncate(time.Minute),
|
||||
metric.FullnessRatio*100, ecConfig.FullnessRatio*100)
|
||||
}
|
||||
}
|
||||
|
||||
return results, hasMore, nil
|
||||
}
|
||||
|
||||
// countTopologyNodes counts volume-server nodes in the active topology, used by
|
||||
// the min-node safety gate.
|
||||
func countTopologyNodes(at *topology.ActiveTopology) int {
|
||||
if at == nil {
|
||||
return 0
|
||||
}
|
||||
topo := at.GetTopologyInfo()
|
||||
if topo == nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, dc := range topo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
n += len(rack.DataNodeInfos)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// buildNodeAddressMap resolves every node's server address once per detection
|
||||
// cycle. ResolveServerAddress rebuilds the full node map on each call, so
|
||||
// resolving per shard destination is O(destinations × topology); callers build
|
||||
// this map once and look up addresses from it.
|
||||
func buildNodeAddressMap(at *topology.ActiveTopology) map[string]string {
|
||||
if at == nil {
|
||||
return nil
|
||||
}
|
||||
allNodes := at.GetAllNodes()
|
||||
m := make(map[string]string, len(allNodes))
|
||||
for id, n := range allNodes {
|
||||
m[id] = string(pb.NewServerAddressFromDataNode(n))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// planECDestinations places all shards of the volume via the shared ecbalancer
|
||||
// policy and returns the per-disk destination plans plus, parallel to them, the
|
||||
// shard ids ecbalancer.Place assigned to each disk (so createECTargets and the
|
||||
// capacity reservations use the real assignment, not a round-robin guess).
|
||||
//
|
||||
// snap is the cycle-wide placement snapshot, built once by the caller and reused
|
||||
// across volumes: Place reserves the shards it assigns into it, so later volumes
|
||||
// in the same detection cycle see the reduced capacity. Rebuilding it per volume
|
||||
// is O(volumes × topology) and times out on large clusters.
|
||||
//
|
||||
// Encode is lenient (PlaceDurabilityFirst): it relaxes caps/anti-affinity/RP as
|
||||
// needed rather than fail, and prefers the source disk type but spills if that
|
||||
// type can't hold every shard. rp is the resolved replica placement (may be nil).
|
||||
func planECDestinations(snap *ecbalancer.Topology, nodeAddresses map[string]string, metric *types.VolumeHealthMetrics, ecConfig *Config, rp *super_block.ReplicaPlacement, dataShards, parityShards int) (*topology.MultiDestinationPlan, [][]uint32, error) {
|
||||
if snap == nil {
|
||||
return nil, nil, fmt.Errorf("EC placement snapshot not available")
|
||||
}
|
||||
if dataShards <= 0 || parityShards <= 0 {
|
||||
return nil, nil, fmt.Errorf("invalid EC ratio: dataShards=%d parityShards=%d", dataShards, parityShards)
|
||||
}
|
||||
totalShards := dataShards + parityShards
|
||||
// Survive losing one disk: each disk holds at most parityShards shards,
|
||||
// so we need at least ceil(totalShards / parityShards) disks.
|
||||
minTotalDisks := (totalShards + parityShards - 1) / parityShards
|
||||
expectedShardSize := uint64(metric.Size) / uint64(dataShards)
|
||||
|
||||
// Encode is greenfield: any EC shards already present for this volume are stale
|
||||
// leftovers from a prior failed attempt, which the task deletes
|
||||
// (cleanupStaleEcShards) before distributing the new shards. Release them so they
|
||||
// don't occupy capacity or skew anti-affinity / per-disk caps during planning.
|
||||
snap.ReleaseVolumeShards(metric.Collection, metric.VolumeID)
|
||||
need := make([]int, totalShards)
|
||||
for i := range need {
|
||||
need[i] = i
|
||||
}
|
||||
res, err := snap.Place(metric.VolumeID, metric.Collection, need, ecbalancer.Constraints{
|
||||
DiskType: metric.DiskType,
|
||||
DiskTypePolicy: ecbalancer.DiskTypePrefer,
|
||||
PreferredTags: ecConfig.PreferredTags,
|
||||
ReplicaPlacement: rp,
|
||||
Ratio: func(string) (int, int) { return dataShards, parityShards },
|
||||
}, ecbalancer.PlaceDurabilityFirst)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if res.SpilledToOtherDiskType {
|
||||
glog.Warningf("EC volume %d: placed shards outside preferred disk type %q", metric.VolumeID, metric.DiskType)
|
||||
}
|
||||
if res.SpilledOutsidePreferredTags {
|
||||
glog.Warningf("EC volume %d: placed shards outside preferred tags %v", metric.VolumeID, ecConfig.PreferredTags)
|
||||
}
|
||||
if len(res.Relaxed) > 0 {
|
||||
// Encode is best-effort (PlaceDurabilityFirst): it relaxes these constraints
|
||||
// rather than defer when the cluster can't satisfy them. Surface it so a tight
|
||||
// replica placement isn't silently weakened; rebalancing tightens the spread.
|
||||
glog.Warningf("EC volume %d: placed with relaxed constraints %v; replica placement not fully satisfied (rebalancing will adjust)", metric.VolumeID, res.Relaxed)
|
||||
}
|
||||
|
||||
// Group the per-shard destinations into one plan per (node,disk), iterating
|
||||
// shard ids in order for determinism.
|
||||
type diskGroup struct {
|
||||
node, rack, dc string
|
||||
diskID uint32
|
||||
shards []uint32
|
||||
}
|
||||
type diskKey struct {
|
||||
node string
|
||||
diskID uint32
|
||||
}
|
||||
groups := make(map[diskKey]*diskGroup, totalShards)
|
||||
order := make([]diskKey, 0, totalShards)
|
||||
for sid := 0; sid < totalShards; sid++ {
|
||||
d, ok := res.Destinations[sid]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("EC volume %d: shard %d was not placed", metric.VolumeID, sid)
|
||||
}
|
||||
key := diskKey{node: d.Node, diskID: d.DiskID}
|
||||
g := groups[key]
|
||||
if g == nil {
|
||||
g = &diskGroup{node: d.Node, rack: d.Rack, dc: d.DataCenter, diskID: d.DiskID}
|
||||
groups[key] = g
|
||||
order = append(order, key)
|
||||
}
|
||||
g.shards = append(g.shards, uint32(sid))
|
||||
}
|
||||
if len(order) < minTotalDisks {
|
||||
return nil, nil, fmt.Errorf("placed onto %d disks, but EC %d+%d needs at least %d so no disk holds more than %d shards",
|
||||
len(order), dataShards, parityShards, minTotalDisks, parityShards)
|
||||
}
|
||||
|
||||
var plans []*topology.DestinationPlan
|
||||
shardsPerPlan := make([][]uint32, 0, len(order))
|
||||
rackCount := make(map[string]int)
|
||||
dcCount := make(map[string]int)
|
||||
for _, key := range order {
|
||||
g := groups[key]
|
||||
targetAddress, ok := nodeAddresses[g.node]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("failed to resolve address for target server %s", g.node)
|
||||
}
|
||||
plans = append(plans, &topology.DestinationPlan{
|
||||
TargetNode: g.node,
|
||||
TargetAddress: targetAddress,
|
||||
TargetDisk: g.diskID,
|
||||
TargetRack: g.rack,
|
||||
TargetDC: g.dc,
|
||||
ExpectedSize: expectedShardSize,
|
||||
})
|
||||
shardsPerPlan = append(shardsPerPlan, g.shards)
|
||||
rackCount[fmt.Sprintf("%s:%s", g.dc, g.rack)]++
|
||||
dcCount[g.dc]++
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Planned EC destinations for volume %d (size=%d bytes): expected shard size=%d bytes, %d shards across %d disks, %d racks, %d DCs",
|
||||
metric.VolumeID, metric.Size, expectedShardSize, totalShards, len(plans), len(rackCount), len(dcCount))
|
||||
|
||||
return &topology.MultiDestinationPlan{
|
||||
Plans: plans,
|
||||
TotalShards: totalShards,
|
||||
SuccessfulRack: len(rackCount),
|
||||
SuccessfulDCs: len(dcCount),
|
||||
}, shardsPerPlan, nil
|
||||
}
|
||||
|
||||
// createECTargets builds TaskTargets from the per-disk plans and the shard ids
|
||||
// ecbalancer.Place assigned to each (shardsPerPlan is parallel to multiPlan.Plans).
|
||||
func createECTargets(multiPlan *topology.MultiDestinationPlan, shardsPerPlan [][]uint32) []*worker_pb.TaskTarget {
|
||||
targets := make([]*worker_pb.TaskTarget, 0, len(multiPlan.Plans))
|
||||
for i, plan := range multiPlan.Plans {
|
||||
shardIDs := shardsPerPlan[i]
|
||||
targets = append(targets, &worker_pb.TaskTarget{
|
||||
Node: plan.TargetAddress,
|
||||
DiskId: plan.TargetDisk,
|
||||
Rack: plan.TargetRack,
|
||||
DataCenter: plan.TargetDC,
|
||||
ShardIds: shardIDs,
|
||||
EstimatedSize: plan.ExpectedSize,
|
||||
})
|
||||
glog.V(2).Infof("EC planning: target %s disk %d assigned shards %v", plan.TargetNode, plan.TargetDisk, shardIDs)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// convertTaskSourcesToProtobuf converts topology.TaskSourceSpec to worker_pb.TaskSource
|
||||
func convertTaskSourcesToProtobuf(sources []topology.TaskSourceSpec, volumeID uint32, activeTopology *topology.ActiveTopology) ([]*worker_pb.TaskSource, error) {
|
||||
var protobufSources []*worker_pb.TaskSource
|
||||
|
||||
for _, source := range sources {
|
||||
serverAddress, err := workerutil.ResolveServerAddress(source.ServerID, activeTopology)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve address for source server %s: %v", source.ServerID, err)
|
||||
}
|
||||
|
||||
pbSource := &worker_pb.TaskSource{
|
||||
Node: serverAddress,
|
||||
DiskId: source.DiskID,
|
||||
DataCenter: source.DataCenter,
|
||||
Rack: source.Rack,
|
||||
}
|
||||
|
||||
// Convert storage impact to estimated size
|
||||
if source.EstimatedSize != nil {
|
||||
pbSource.EstimatedSize = uint64(*source.EstimatedSize)
|
||||
}
|
||||
|
||||
// Populated ShardIds is the wire-level marker that flags an
|
||||
// EC-shard cleanup source; the worker routes it through
|
||||
// cleanupStaleEcShards and skips it in getReplicas.
|
||||
switch source.CleanupType {
|
||||
case topology.CleanupVolumeReplica:
|
||||
pbSource.VolumeId = volumeID
|
||||
case topology.CleanupECShards:
|
||||
pbSource.VolumeId = volumeID
|
||||
pbSource.ShardIds = append([]uint32(nil), source.ShardIds...)
|
||||
}
|
||||
|
||||
protobufSources = append(protobufSources, pbSource)
|
||||
}
|
||||
|
||||
return protobufSources, nil
|
||||
}
|
||||
|
||||
// createECTaskParams creates clean EC task parameters (destinations now in unified targets).
|
||||
// sourceDiskType is forwarded to VolumeEcShardsMount so the resulting EC volume
|
||||
// reports under the source's disk type rather than the target location's (#9423).
|
||||
func createECTaskParams(dataShards, parityShards int, sourceDiskType string) *worker_pb.ErasureCodingTaskParams {
|
||||
return &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: int32(dataShards),
|
||||
ParityShards: int32(parityShards),
|
||||
SourceDiskType: sourceDiskType,
|
||||
}
|
||||
}
|
||||
|
||||
// findVolumeReplicaLocations finds all replica locations (server + disk) for the specified volume
|
||||
// Uses O(1) indexed lookup for optimal performance on large clusters.
|
||||
func findVolumeReplicaLocations(activeTopology *topology.ActiveTopology, volumeID uint32, collection string) []topology.VolumeReplica {
|
||||
if activeTopology == nil {
|
||||
return nil
|
||||
}
|
||||
return activeTopology.GetVolumeLocations(volumeID, collection)
|
||||
}
|
||||
|
||||
// findExistingECShards finds existing EC shards for a volume (from previous failed EC attempts)
|
||||
// Uses O(1) indexed lookup for optimal performance on large clusters.
|
||||
func findExistingECShards(activeTopology *topology.ActiveTopology, volumeID uint32, collection string) []topology.VolumeReplica {
|
||||
if activeTopology == nil {
|
||||
return nil
|
||||
}
|
||||
return activeTopology.GetECShardLocations(volumeID, collection)
|
||||
}
|
||||
|
||||
// cleanupOrphanSourceReplicas deletes any regular volume replicas still
|
||||
// present in the topology for (volumeID, collection) after re-verifying that
|
||||
// the full EC shard set is intact. Caller must hold expectedShards equal to
|
||||
// the configured totalShards count. Issues VolumeDelete RPC to each replica
|
||||
// server's address — that endpoint only touches the regular volume on the
|
||||
// targeted server, never EC shards (those live in a separate store path).
|
||||
// The cluster-wide `volume.delete` shell command is what would have nuked
|
||||
// the EC shards too; the targeted RPC used here is safe by construction.
|
||||
// Returns the count of replicas successfully deleted plus any error.
|
||||
func cleanupOrphanSourceReplicas(ctx context.Context, clusterInfo *types.ClusterInfo, metric *types.VolumeHealthMetrics, expectedShards int) (int, error) {
|
||||
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
|
||||
return 0, fmt.Errorf("active topology unavailable")
|
||||
}
|
||||
if clusterInfo.GrpcDialOption == nil {
|
||||
return 0, fmt.Errorf("grpc dial option unavailable")
|
||||
}
|
||||
|
||||
// Re-verify shard completeness right before acting. Defensive: detection
|
||||
// processes many volumes sequentially and the topology snapshot we built
|
||||
// at start-of-detection could have lost shards in between (a volume
|
||||
// server going down between iterations). Refusing to delete the source
|
||||
// when we can no longer prove the shards are complete is the safer
|
||||
// failure mode — the source replica is the only complete copy.
|
||||
actualShards := countExistingEcShardsForVolume(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
|
||||
if actualShards < expectedShards {
|
||||
return 0, fmt.Errorf("EC shard set shrank between detection and cleanup (%d < %d); refusing to delete source replica", actualShards, expectedShards)
|
||||
}
|
||||
|
||||
replicas := findVolumeReplicaLocations(clusterInfo.ActiveTopology, metric.VolumeID, metric.Collection)
|
||||
if len(replicas) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
var deleteErrors []string
|
||||
for _, replica := range replicas {
|
||||
serverAddress := replica.ServerID
|
||||
var isReadOnly bool
|
||||
err := operation.WithVolumeServerClient(false, pb.ServerAddress(serverAddress), clusterInfo.GrpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
// Re-probe before deleting: only a still-readonly source replica is
|
||||
// safe to remove. One that came back writable may have accepted
|
||||
// writes the EC shards do not contain, so deleting it loses data.
|
||||
status, statusErr := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: metric.VolumeID})
|
||||
if statusErr != nil {
|
||||
return statusErr
|
||||
}
|
||||
isReadOnly = status.GetIsReadOnly()
|
||||
if !isReadOnly {
|
||||
return nil
|
||||
}
|
||||
_, deleteErr := client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
|
||||
VolumeId: metric.VolumeID,
|
||||
OnlyEmpty: false,
|
||||
})
|
||||
return deleteErr
|
||||
})
|
||||
if err != nil {
|
||||
deleteErrors = append(deleteErrors, fmt.Sprintf("server %s: %v", serverAddress, err))
|
||||
continue
|
||||
}
|
||||
if !isReadOnly {
|
||||
glog.Warningf("EC Detection: source replica for volume %d on %s is writable; not deleting (may hold writes the EC shards lack)", metric.VolumeID, serverAddress)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
glog.V(1).Infof("EC Detection: deleted orphan regular replica for volume %d on %s", metric.VolumeID, serverAddress)
|
||||
}
|
||||
|
||||
if len(deleteErrors) > 0 {
|
||||
return deleted, fmt.Errorf("%d of %d replica delete(s) failed: %s", len(deleteErrors), len(replicas), strings.Join(deleteErrors, "; "))
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// countExistingEcShardsForVolume counts (volumeID, collection) EC shards in the
|
||||
// topology, counting only the single largest encode generation; see
|
||||
// ec.CountExistingEcShardsForVolume for the generation semantics.
|
||||
func countExistingEcShardsForVolume(activeTopology *topology.ActiveTopology, volumeID uint32, collection string) int {
|
||||
if activeTopology == nil {
|
||||
return 0
|
||||
}
|
||||
return ec.CountExistingEcShardsForVolume(activeTopology.GetTopologyInfo(), volumeID, collection)
|
||||
}
|
||||
93
weed/worker/tasks/erasure_coding/detection_disk_type_test.go
Normal file
93
weed/worker/tasks/erasure_coding/detection_disk_type_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPlanECDestinationsPrefersSourceDiskType_FullCluster covers the
|
||||
// #9423 happy path through the worker plumbing: every node carries both
|
||||
// an HDD and an SSD disk, so the cluster has enough SSD slots to fully
|
||||
// satisfy a 10+4 placement. With metric.DiskType="ssd", every selected
|
||||
// destination must land on an SSD disk.
|
||||
func TestPlanECDestinationsPrefersSourceDiskType_FullCluster(t *testing.T) {
|
||||
// 14 nodes × 2 disks per node (hdd + ssd) — enough SSD slots alone
|
||||
// for a 10+4 layout with one-shard-per-(server,disk) diversity.
|
||||
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd", "ssd"}, 100, 0, "")
|
||||
|
||||
metric := &types.VolumeHealthMetrics{
|
||||
VolumeID: 1,
|
||||
Server: "10.0.0.1:8080",
|
||||
Size: 100 * 1024 * 1024,
|
||||
Collection: "",
|
||||
DiskType: "ssd", // the property being plumbed end-to-end
|
||||
}
|
||||
|
||||
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses := buildNodeAddressMap(activeTopology)
|
||||
plan, _, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plan.Plans, erasure_coding.TotalShardsCount)
|
||||
|
||||
// buildActiveTopology assigns DiskID by index into the diskTypes
|
||||
// slice, so SSD is DiskID 1. Every plan entry must point at an SSD
|
||||
// disk — anything else means metric.DiskType was dropped on the way
|
||||
// to the placement layer.
|
||||
for _, p := range plan.Plans {
|
||||
assert.Equalf(t, uint32(1), p.TargetDisk,
|
||||
"target %s diskID = %d, want SSD disk (DiskID=1)", p.TargetNode, p.TargetDisk)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanECDestinationsSpillsToOtherDiskType_WhenPreferredScarce pins
|
||||
// the prefer-with-spillover policy: only one node in the cluster has an
|
||||
// SSD disk; the rest are HDD-only. A 10+4 placement must still succeed
|
||||
// (no hard fail), filling the one SSD slot first and the remaining 13
|
||||
// from HDD nodes. Without the spillover behavior this would either
|
||||
// error out or starve the placement.
|
||||
func TestPlanECDestinationsSpillsToOtherDiskType_WhenPreferredScarce(t *testing.T) {
|
||||
// Start with every node carrying both HDD and SSD, then strip SSD
|
||||
// from all but the first node so the SSD pool is too small alone.
|
||||
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd", "ssd"}, 100, 0, "")
|
||||
topo := activeTopology.GetTopologyInfo()
|
||||
for _, dc := range topo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
for i, node := range rack.DataNodeInfos {
|
||||
if i == 0 {
|
||||
continue // keep the first node's SSD
|
||||
}
|
||||
delete(node.DiskInfos, "ssd")
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NoError(t, activeTopology.UpdateTopology(topo))
|
||||
|
||||
metric := &types.VolumeHealthMetrics{
|
||||
VolumeID: 2,
|
||||
Server: "10.0.0.1:8080",
|
||||
Size: 100 * 1024 * 1024,
|
||||
Collection: "",
|
||||
DiskType: "ssd",
|
||||
}
|
||||
|
||||
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses := buildNodeAddressMap(activeTopology)
|
||||
plan, _, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plan.Plans, erasure_coding.TotalShardsCount)
|
||||
|
||||
// Exactly one SSD target (the only SSD node), the rest on HDD.
|
||||
ssdHits := 0
|
||||
for _, p := range plan.Plans {
|
||||
if p.TargetDisk == 1 {
|
||||
ssdHits++
|
||||
}
|
||||
}
|
||||
assert.Equalf(t, 1, ssdHits,
|
||||
"expected exactly 1 SSD placement (the only SSD node in the cluster), got %d", ssdHits)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type ecShardSet struct {
|
||||
bits uint32
|
||||
encodeTsNs int64
|
||||
}
|
||||
|
||||
func ecBits(shardIds ...int) uint32 {
|
||||
var b uint32
|
||||
for _, id := range shardIds {
|
||||
b |= uint32(1) << uint(id)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// buildEcTopology builds an ActiveTopology where each shard set lives on its own
|
||||
// node, so countExistingEcShardsForVolume can be exercised across encode
|
||||
// generations spread over different disks.
|
||||
func buildEcTopology(t *testing.T, vid uint32, collection string, sets []ecShardSet) *topology.ActiveTopology {
|
||||
t.Helper()
|
||||
at := topology.NewActiveTopology(10)
|
||||
nodes := make([]*master_pb.DataNodeInfo, 0, len(sets))
|
||||
for i, set := range sets {
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: fmt.Sprintf("10.0.0.%d:8080", i+1),
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
MaxVolumeCount: 100,
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: vid, Collection: collection, EcIndexBits: set.bits, EncodeTsNs: set.encodeTsNs},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
topologyInfo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: nodes}},
|
||||
}},
|
||||
}
|
||||
require.NoError(t, at.UpdateTopology(topologyInfo))
|
||||
return at
|
||||
}
|
||||
|
||||
// Two interrupted encode runs whose shard sets overlap in count must NOT be
|
||||
// unioned into a false-complete set. countExistingEcShardsForVolume returns the
|
||||
// largest single generation's shard count, not the cross-run union.
|
||||
func TestCountExistingEcShards_DoesNotUnionAcrossGenerations(t *testing.T) {
|
||||
const vid = uint32(42)
|
||||
|
||||
// run A holds shards 0-6 (7 shards, encode_ts 1); run B holds shards 7-13
|
||||
// (7 shards, encode_ts 2). The union would be all 14; the per-generation max
|
||||
// is 7, so the orphan-source delete trigger (>= 14) must not fire.
|
||||
at := buildEcTopology(t, vid, "", []ecShardSet{
|
||||
{bits: ecBits(0, 1, 2, 3, 4, 5, 6), encodeTsNs: 1},
|
||||
{bits: ecBits(7, 8, 9, 10, 11, 12, 13), encodeTsNs: 2},
|
||||
})
|
||||
require.Equal(t, 7, countExistingEcShardsForVolume(at, vid, ""))
|
||||
}
|
||||
|
||||
// A single complete generation still counts as complete, so genuine stuck-source
|
||||
// cleanup keeps working.
|
||||
func TestCountExistingEcShards_SingleCompleteGeneration(t *testing.T) {
|
||||
const vid = uint32(43)
|
||||
at := buildEcTopology(t, vid, "", []ecShardSet{
|
||||
{bits: ecBits(0, 1, 2, 3, 4, 5, 6), encodeTsNs: 5},
|
||||
{bits: ecBits(7, 8, 9, 10, 11, 12, 13), encodeTsNs: 5},
|
||||
})
|
||||
require.Equal(t, 14, countExistingEcShardsForVolume(at, vid, ""))
|
||||
}
|
||||
|
||||
// Shards from pre-upgrade servers report encode_ts_ns==0; a complete 0-generation
|
||||
// set is still recognized as complete (its own bucket).
|
||||
func TestCountExistingEcShards_PreUpgradeZeroGeneration(t *testing.T) {
|
||||
const vid = uint32(44)
|
||||
at := buildEcTopology(t, vid, "", []ecShardSet{
|
||||
{bits: ecBits(0, 1, 2, 3, 4, 5, 6), encodeTsNs: 0},
|
||||
{bits: ecBits(7, 8, 9, 10, 11, 12, 13), encodeTsNs: 0},
|
||||
})
|
||||
require.Equal(t, 14, countExistingEcShardsForVolume(at, vid, ""))
|
||||
}
|
||||
499
weed/worker/tasks/erasure_coding/detection_test.go
Normal file
499
weed/worker/tasks/erasure_coding/detection_test.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPlanECDestinationsUsesPlanner(t *testing.T) {
|
||||
activeTopology := buildActiveTopology(t, 7, []string{"hdd", "ssd"}, 100, 0, "")
|
||||
|
||||
metric := &types.VolumeHealthMetrics{
|
||||
VolumeID: 1,
|
||||
Server: "10.0.0.1:8080",
|
||||
Size: 100 * 1024 * 1024,
|
||||
Collection: "",
|
||||
}
|
||||
|
||||
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses := buildNodeAddressMap(activeTopology)
|
||||
plan, shardsPerPlan, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plan)
|
||||
requireAllShardsPlaced(t, plan, shardsPerPlan)
|
||||
}
|
||||
|
||||
// TestDetectionSkipsWhenECShardsAlreadyExist guards against issue #9448: a
|
||||
// regular replica that survived a previous successful EC encode (source
|
||||
// delete didn't clean it up for some reason) gets re-proposed for encoding,
|
||||
// the new encode collides with the already-mounted shards on the targets
|
||||
// ("ec volume %d is mounted; refusing overwrite"), and detection loops
|
||||
// forever on the same volume. Detection must see the existing shards and
|
||||
// skip the volume so an admin can clean it up out-of-band.
|
||||
//
|
||||
// The guard fires ONLY when the EC shard set is complete (count >=
|
||||
// totalShards), so a partially-distributed previous attempt still falls
|
||||
// through to the existing recovery branch in the encode path.
|
||||
func TestDetectionSkipsWhenECShardsAlreadyExist(t *testing.T) {
|
||||
const volumeID uint32 = 42
|
||||
activeTopology := buildStuckSourceTopology(t, volumeID, erasure_coding.TotalShardsCount)
|
||||
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
|
||||
metrics := buildStuckSourceMetrics(volumeID, "127.0.0.1:8080")
|
||||
|
||||
results, hasMore, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 0)
|
||||
require.NoError(t, err)
|
||||
require.False(t, hasMore)
|
||||
require.Empty(t, results, "stuck source replica with all EC shards present must not yield a new encoding proposal")
|
||||
}
|
||||
|
||||
// TestDetectionAllowsRegularReplicaWhenShardsPartial covers the partial-EC
|
||||
// branch of the #9448 guard: when fewer than totalShards exist, the volume
|
||||
// is allowed to flow through to the normal encoding path so the existing
|
||||
// recovery branch (the `existingECShards` block in the encode arm) can fold
|
||||
// the partial shards into the new task. A bug here would either (a) skip
|
||||
// the volume entirely or (b) emit a proposal that later collides on the
|
||||
// mounted shards.
|
||||
func TestDetectionAllowsRegularReplicaWhenShardsPartial(t *testing.T) {
|
||||
const volumeID uint32 = 43
|
||||
activeTopology := buildStuckSourceTopology(t, volumeID, erasure_coding.DataShardsCount-1)
|
||||
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
|
||||
metrics := buildStuckSourceMetrics(volumeID, "127.0.0.1:8080")
|
||||
|
||||
results, _, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 0)
|
||||
require.NoError(t, err)
|
||||
// Partial shards are not a "stuck source" — the encode arm must keep
|
||||
// its chance to either propose a fresh task that folds the partial
|
||||
// shards into cleanup, or fail planning on the constrained topology.
|
||||
// We don't require len(results) > 0 because the constrained topology
|
||||
// (one disk per node, the orphaned shards already taking slots) can
|
||||
// legitimately fail destination planning. The assertion that matters
|
||||
// is: the #9448 guard did NOT silently swallow the volume into a
|
||||
// skippedAlreadyEC counter, and any emitted result is still an EC
|
||||
// task and not a no-op.
|
||||
for _, r := range results {
|
||||
require.Equal(t, types.TaskTypeErasureCoding, r.TaskType, "any emitted result should still be an EC task, not a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
// buildStuckSourceTopology constructs a topology that mimics the #9448 stuck
|
||||
// state: a regular volume replica on node 0 plus `presentShardCount` EC
|
||||
// shards distributed across nodes 0..presentShardCount-1.
|
||||
func buildStuckSourceTopology(t *testing.T, volumeID uint32, presentShardCount int) *topology.ActiveTopology {
|
||||
t.Helper()
|
||||
require.LessOrEqual(t, presentShardCount, erasure_coding.TotalShardsCount)
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
nodes := make([]*master_pb.DataNodeInfo, 0, erasure_coding.TotalShardsCount)
|
||||
for i := 0; i < erasure_coding.TotalShardsCount; i++ {
|
||||
nodeID := fmt.Sprintf("127.0.0.1:%d", 8080+i)
|
||||
diskInfo := &master_pb.DiskInfo{
|
||||
DiskId: 0,
|
||||
VolumeCount: 1,
|
||||
MaxVolumeCount: 100,
|
||||
}
|
||||
if i < presentShardCount {
|
||||
diskInfo.EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{{
|
||||
Id: volumeID,
|
||||
Collection: "",
|
||||
EcIndexBits: uint32(1) << uint(i),
|
||||
DiskId: 0,
|
||||
}}
|
||||
}
|
||||
if i == 0 {
|
||||
diskInfo.VolumeInfos = []*master_pb.VolumeInformationMessage{{
|
||||
Id: volumeID,
|
||||
DiskId: 0,
|
||||
DiskType: "hdd",
|
||||
Size: 200 * 1024 * 1024,
|
||||
}}
|
||||
}
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: nodeID,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": diskInfo},
|
||||
})
|
||||
}
|
||||
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: nodes,
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
return activeTopology
|
||||
}
|
||||
|
||||
// buildStuckSourceMetrics returns a metric that already satisfies the EC
|
||||
// criteria (Age, FullnessRatio, Size), with `Age` derived from `LastModified`
|
||||
// so the two fields stay consistent for any reader.
|
||||
func buildStuckSourceMetrics(volumeID uint32, server string) []*types.VolumeHealthMetrics {
|
||||
lastModified := time.Now().Add(-2 * time.Hour)
|
||||
return []*types.VolumeHealthMetrics{{
|
||||
VolumeID: volumeID,
|
||||
Server: server,
|
||||
Size: 200 * 1024 * 1024,
|
||||
Collection: "",
|
||||
FullnessRatio: 0.96,
|
||||
LastModified: lastModified,
|
||||
Age: time.Since(lastModified),
|
||||
}}
|
||||
}
|
||||
|
||||
// TestCountExistingEcShardsForVolume verifies that the helper walks the
|
||||
// EcIndexBits bitmap (not just len(EcShardInfos)) so it correctly counts
|
||||
// distinct shard ids even when a single info entry on one disk carries
|
||||
// multiple shards.
|
||||
func TestCountExistingEcShardsForVolume(t *testing.T) {
|
||||
const volumeID uint32 = 99
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "127.0.0.1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
MaxVolumeCount: 100,
|
||||
// One info entry, three shards present (ids 0, 2, 5).
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
|
||||
Id: volumeID,
|
||||
Collection: "",
|
||||
EcIndexBits: (uint32(1) << 0) | (uint32(1) << 2) | (uint32(1) << 5),
|
||||
DiskId: 0,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "127.0.0.1:8081",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
MaxVolumeCount: 100,
|
||||
// One info entry, one shard (id 3) — overlaps with neither.
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
|
||||
Id: volumeID,
|
||||
Collection: "",
|
||||
EcIndexBits: uint32(1) << 3,
|
||||
DiskId: 0,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
|
||||
assert.Equal(t, 4, countExistingEcShardsForVolume(activeTopology, volumeID, ""))
|
||||
assert.Equal(t, 0, countExistingEcShardsForVolume(activeTopology, volumeID, "other-collection"))
|
||||
assert.Equal(t, 0, countExistingEcShardsForVolume(nil, volumeID, ""))
|
||||
}
|
||||
|
||||
func TestDetectionContextCancellation(t *testing.T) {
|
||||
activeTopology := buildActiveTopology(t, 5, []string{"hdd", "ssd"}, 50, 0, "")
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
|
||||
metrics := buildVolumeMetricsForIDs(50)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, _, err := Detection(ctx, metrics, clusterInfo, NewDefaultConfig(), 0)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
// The admin UI collection filter takes a list, and the two mode sentinels must
|
||||
// not be read as a collection name.
|
||||
func TestDetectionCollectionFilter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
filter string
|
||||
want int
|
||||
}{
|
||||
{filter: "", want: 3},
|
||||
{filter: "ALL_COLLECTIONS", want: 3},
|
||||
{filter: "collection-a", want: 3},
|
||||
{filter: "collection-a,collection-b", want: 3},
|
||||
{filter: " collection-b , collection-a ", want: 3},
|
||||
{filter: "collection-*", want: 3},
|
||||
{filter: "collection-b,collection-c", want: 0},
|
||||
{filter: "collection-ab", want: 0},
|
||||
} {
|
||||
t.Run(tc.filter, func(t *testing.T) {
|
||||
// A fresh topology per case: planned destinations reserve capacity.
|
||||
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd"}, 20, 0, "collection-a")
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
|
||||
|
||||
metrics := buildVolumeMetricsForIDs(3)
|
||||
for _, metric := range metrics {
|
||||
metric.Collection = "collection-a"
|
||||
}
|
||||
|
||||
config := NewDefaultConfig()
|
||||
config.CollectionFilter = tc.filter
|
||||
results, _, err := Detection(context.Background(), metrics, clusterInfo, config, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectionMaxResultsHonorsLimit(t *testing.T) {
|
||||
// One node per shard so each shard gets its own disk (#9369).
|
||||
activeTopology := buildActiveTopology(t, erasure_coding.TotalShardsCount, []string{"hdd"}, 20, 0, "")
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
|
||||
metrics := buildVolumeMetricsForIDs(3)
|
||||
|
||||
results, hasMore, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 1)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
assert.True(t, hasMore)
|
||||
}
|
||||
|
||||
// #9369: 7 servers × 2 physical HDDs must yield 14 distinct (server, disk_id)
|
||||
// destinations, not 7 destinations doubled up on the same disk.
|
||||
func TestPlanECDestinationsSpreadsAcrossPhysicalDisks(t *testing.T) {
|
||||
const numServers = 7
|
||||
const disksPerServer = 2
|
||||
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
nodes := make([]*master_pb.DataNodeInfo, 0, numServers)
|
||||
for i := 1; i <= numServers; i++ {
|
||||
volumeInfos := make([]*master_pb.VolumeInformationMessage, 0, disksPerServer)
|
||||
for d := uint32(0); d < disksPerServer; d++ {
|
||||
volumeInfos = append(volumeInfos, &master_pb.VolumeInformationMessage{
|
||||
Id: uint32(i*100 + int(d)),
|
||||
DiskId: d,
|
||||
DiskType: "hdd",
|
||||
})
|
||||
}
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: fmt.Sprintf("127.0.0.1:%d", 8080+i),
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
VolumeCount: int64(disksPerServer),
|
||||
MaxVolumeCount: 200,
|
||||
VolumeInfos: volumeInfos,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: nodes,
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
|
||||
metric := &types.VolumeHealthMetrics{
|
||||
VolumeID: 42,
|
||||
Server: "127.0.0.1:8081",
|
||||
Size: 100 * 1024 * 1024,
|
||||
Collection: "",
|
||||
}
|
||||
|
||||
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses := buildNodeAddressMap(activeTopology)
|
||||
plan, shardsPerPlan, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plan)
|
||||
requireAllShardsPlaced(t, plan, shardsPerPlan)
|
||||
}
|
||||
|
||||
func TestPlanECDestinationsFailsWithInsufficientCapacity(t *testing.T) {
|
||||
activeTopology := buildActiveTopology(t, 1, []string{"hdd"}, 1, 1, "")
|
||||
|
||||
metric := &types.VolumeHealthMetrics{
|
||||
VolumeID: 2,
|
||||
Server: "10.0.0.1:8080",
|
||||
Size: 10 * 1024 * 1024,
|
||||
Collection: "",
|
||||
}
|
||||
|
||||
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses := buildNodeAddressMap(activeTopology)
|
||||
_, _, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// #9586: with fewer single-disk servers than total shards, EC must still plan
|
||||
// by packing several shards onto a disk (ec.encode's "4,4,3,3" fallback) rather
|
||||
// than refusing. The reporter has 8 single-disk servers across 3 racks and a
|
||||
// 10+4 scheme — 8 disks for 14 shards. minTotalDisks (ceil(14/4)=4) keeps any
|
||||
// disk under parityShards shards, so durability holds.
|
||||
func TestPlanECDestinationsPacksWhenFewerDisksThanShards(t *testing.T) {
|
||||
const numServers = 8
|
||||
// rack3 holds 4 servers, rack1 and rack2 hold 2 each, mirroring the report.
|
||||
racks := []string{"rack3", "rack3", "rack3", "rack3", "rack1", "rack1", "rack2", "rack2"}
|
||||
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
rackNodes := make(map[string][]*master_pb.DataNodeInfo)
|
||||
for i := 0; i < numServers; i++ {
|
||||
nodeID := fmt.Sprintf("192.168.1.%d:%d", 143+i/3, 8080+i)
|
||||
rackNodes[racks[i]] = append(rackNodes[racks[i]], &master_pb.DataNodeInfo{
|
||||
Id: nodeID,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
VolumeCount: 1,
|
||||
MaxVolumeCount: 200,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{{
|
||||
Id: uint32(i + 1),
|
||||
DiskId: 0,
|
||||
DiskType: "hdd",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
rackInfos := make([]*master_pb.RackInfo, 0, len(rackNodes))
|
||||
for _, rackID := range []string{"rack1", "rack2", "rack3"} {
|
||||
rackInfos = append(rackInfos, &master_pb.RackInfo{Id: rackID, DataNodeInfos: rackNodes[rackID]})
|
||||
}
|
||||
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{Id: "dc1", RackInfos: rackInfos}},
|
||||
}))
|
||||
|
||||
metric := &types.VolumeHealthMetrics{
|
||||
VolumeID: 4569,
|
||||
Server: "192.168.1.145:8081",
|
||||
Size: 100 * 1024 * 1024,
|
||||
Collection: "",
|
||||
}
|
||||
|
||||
snap := ecbalancer.FromActiveTopology(activeTopology, erasure_coding.DataShardsCount)
|
||||
nodeAddresses := buildNodeAddressMap(activeTopology)
|
||||
plan, shardsPerPlan, err := planECDestinations(snap, nodeAddresses, metric, NewDefaultConfig(), nil, erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plan)
|
||||
// Packed onto the available disks: more than one shard per disk but never more
|
||||
// than the 8 disks, and at least the durability floor of distinct disks.
|
||||
require.LessOrEqual(t, len(plan.Plans), numServers)
|
||||
require.GreaterOrEqual(t, len(plan.Plans), (erasure_coding.TotalShardsCount+erasure_coding.ParityShardsCount-1)/erasure_coding.ParityShardsCount)
|
||||
|
||||
// createECTargets must cover all 14 shards exactly once, packing onto the
|
||||
// available disks without any disk exceeding parityShards shards.
|
||||
targets := createECTargets(plan, shardsPerPlan)
|
||||
require.Equal(t, len(plan.Plans), len(targets))
|
||||
|
||||
seenShards := make(map[uint32]bool)
|
||||
for _, target := range targets {
|
||||
require.LessOrEqual(t, len(target.ShardIds), erasure_coding.ParityShardsCount,
|
||||
"no disk may hold more than parityShards shards, else losing it loses the volume")
|
||||
for _, shardId := range target.ShardIds {
|
||||
require.False(t, seenShards[shardId], "shard %d assigned to more than one target", shardId)
|
||||
seenShards[shardId] = true
|
||||
}
|
||||
}
|
||||
require.Len(t, seenShards, erasure_coding.TotalShardsCount, "every shard must be placed exactly once")
|
||||
}
|
||||
|
||||
// requireAllShardsPlaced asserts every EC shard landed exactly once, on a distinct
|
||||
// (node,disk) target, with no disk holding more than parityShards shards (so losing
|
||||
// any one disk cannot lose the volume). shardsPerPlan is parallel to plan.Plans.
|
||||
func requireAllShardsPlaced(t *testing.T, plan *topology.MultiDestinationPlan, shardsPerPlan [][]uint32) {
|
||||
t.Helper()
|
||||
require.Equal(t, len(plan.Plans), len(shardsPerPlan), "one shard list per plan entry")
|
||||
keys := make(map[string]bool, len(plan.Plans))
|
||||
seen := make(map[uint32]bool)
|
||||
for i, p := range plan.Plans {
|
||||
key := fmt.Sprintf("%s:%d", p.TargetNode, p.TargetDisk)
|
||||
require.False(t, keys[key], "duplicate (node,disk) target %s", key)
|
||||
keys[key] = true
|
||||
require.LessOrEqual(t, len(shardsPerPlan[i]), erasure_coding.ParityShardsCount,
|
||||
"disk %s holds %d shards, over parityShards", key, len(shardsPerPlan[i]))
|
||||
for _, s := range shardsPerPlan[i] {
|
||||
require.False(t, seen[s], "shard %d placed more than once", s)
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
require.Len(t, seen, erasure_coding.TotalShardsCount, "every shard must be placed exactly once")
|
||||
}
|
||||
|
||||
func buildVolumeMetricsForIDs(count int) []*types.VolumeHealthMetrics {
|
||||
metrics := make([]*types.VolumeHealthMetrics, 0, count)
|
||||
now := time.Now()
|
||||
for id := 1; id <= count; id++ {
|
||||
metrics = append(metrics, &types.VolumeHealthMetrics{
|
||||
VolumeID: uint32(id),
|
||||
Server: "10.0.0.1:8080",
|
||||
Size: 200 * 1024 * 1024,
|
||||
Collection: "",
|
||||
FullnessRatio: 0.96,
|
||||
LastModified: now.Add(-2 * time.Hour),
|
||||
Age: 2 * time.Hour,
|
||||
})
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func buildActiveTopology(t *testing.T, nodeCount int, diskTypes []string, maxVolumeCount, usedVolumeCount int64, collection string) *topology.ActiveTopology {
|
||||
t.Helper()
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
|
||||
nodes := make([]*master_pb.DataNodeInfo, 0, nodeCount)
|
||||
for i := 1; i <= nodeCount; i++ {
|
||||
diskInfos := make(map[string]*master_pb.DiskInfo)
|
||||
for diskIndex, diskType := range diskTypes {
|
||||
used := usedVolumeCount
|
||||
if used > maxVolumeCount {
|
||||
used = maxVolumeCount
|
||||
}
|
||||
volumeInfos := make([]*master_pb.VolumeInformationMessage, 0, 200)
|
||||
for vid := 1; vid <= 200; vid++ {
|
||||
volumeInfos = append(volumeInfos, &master_pb.VolumeInformationMessage{
|
||||
Id: uint32(vid),
|
||||
Collection: collection,
|
||||
DiskId: uint32(diskIndex),
|
||||
})
|
||||
}
|
||||
diskInfos[diskType] = &master_pb.DiskInfo{
|
||||
DiskId: uint32(diskIndex),
|
||||
VolumeCount: used,
|
||||
MaxVolumeCount: maxVolumeCount,
|
||||
VolumeInfos: volumeInfos,
|
||||
}
|
||||
}
|
||||
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: fmt.Sprintf("10.0.0.%d:8080", i),
|
||||
DiskInfos: diskInfos,
|
||||
})
|
||||
}
|
||||
|
||||
topologyInfo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: nodes,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, activeTopology.UpdateTopology(topologyInfo))
|
||||
return activeTopology
|
||||
}
|
||||
1192
weed/worker/tasks/erasure_coding/ec_task.go
Normal file
1192
weed/worker/tasks/erasure_coding/ec_task.go
Normal file
File diff suppressed because it is too large
Load Diff
113
weed/worker/tasks/erasure_coding/ec_task_delete_swallow_test.go
Normal file
113
weed/worker/tasks/erasure_coding/ec_task_delete_swallow_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// One reachable replica + one unreachable: the reachable delete still
|
||||
// succeeds, and the function surfaces an error naming the failure.
|
||||
func TestDeleteOriginalVolumeSurfacesReplicaFailures(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(91842)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 918420, 0x91842042)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("delete-surface-content-for-issue-9184"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
require.Equal(t, http.StatusCreated, uploadResp.StatusCode)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"delete-surface-fix",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
|
||||
unreachable := "127.0.0.1:1"
|
||||
task.sources = []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: clusterHarness.VolumeServerAddress(),
|
||||
VolumeId: volumeID,
|
||||
},
|
||||
{
|
||||
Node: unreachable,
|
||||
VolumeId: volumeID,
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := task.deleteOriginalVolume(ctx)
|
||||
require.Error(t, err, "deleteOriginalVolume must surface replica delete failures (#9184)")
|
||||
require.Contains(t, err.Error(), unreachable,
|
||||
"returned error should name the replica that failed: %v", err)
|
||||
require.True(t,
|
||||
strings.Contains(err.Error(), "failed to delete"),
|
||||
"returned error should describe what failed: %v", err)
|
||||
|
||||
_, statusErr := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
require.Error(t, statusErr,
|
||||
"reachable replica %d should have been deleted before failure was surfaced", volumeID)
|
||||
}
|
||||
|
||||
func TestDeleteOriginalVolumeSucceedsWhenAllReplicasReachable(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(91844)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 918440, 0x91844042)
|
||||
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("delete-happy-path-content-for-issue-9184"))
|
||||
_ = framework.ReadAllAndClose(t, uploadResp)
|
||||
require.Equal(t, http.StatusCreated, uploadResp.StatusCode)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"delete-happy-path",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.sources = []*worker_pb.TaskSource{
|
||||
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, task.deleteOriginalVolume(ctx))
|
||||
|
||||
_, statusErr := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
require.Error(t, statusErr, "volume %d should be gone after successful delete", volumeID)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestReplicasPendingDelete(t *testing.T) {
|
||||
replicas := []string{"a:8080", "b:8080", "c:8080"}
|
||||
|
||||
require.Equal(t, replicas, replicasPendingDelete(replicas, nil),
|
||||
"nil swept set leaves the list unchanged")
|
||||
require.ElementsMatch(t, []string{"a:8080", "c:8080"},
|
||||
replicasPendingDelete(replicas, map[string]bool{"b:8080": true}),
|
||||
"swept node is excluded")
|
||||
require.Empty(t,
|
||||
replicasPendingDelete(replicas, map[string]bool{"a:8080": true, "b:8080": true, "c:8080": true}),
|
||||
"all swept yields nothing left to delete")
|
||||
}
|
||||
|
||||
// The sweep leaves a node in place only when VolumeDelete(OnlyEmpty) reports the
|
||||
// volume holds data or no longer exists. An unreachable node leaves its state
|
||||
// unknown and must propagate so the encode fails rather than force-deleting it
|
||||
// later over the shared .vif.
|
||||
func TestIsExpectedSweepSkip(t *testing.T) {
|
||||
require.True(t, isExpectedSweepSkip(errors.New("DeleteVolume 5: volume not empty")))
|
||||
require.True(t, isExpectedSweepSkip(errors.New("volume 5 not found on disk")))
|
||||
require.False(t, isExpectedSweepSkip(errors.New("rpc error: code = Unavailable desc = connection refused")))
|
||||
require.False(t, isExpectedSweepSkip(errors.New("context deadline exceeded")))
|
||||
}
|
||||
|
||||
// The pre-distribute sweep must delete a 0-byte stub replica (so its shared
|
||||
// <collection>_<vid>.vif can't be clobbered by a later original-delete and
|
||||
// can't shadow the incoming EC files) while leaving a data-bearing replica
|
||||
// untouched — VolumeDelete(OnlyEmpty=true) is the guard. A data-bearing
|
||||
// replica is deleted later, only after the EC shard set is verified.
|
||||
func TestSweepEmptyReplicasDeletesStubKeepsData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
server := clusterHarness.VolumeServerAddress()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// An empty allocated volume is a superblock-only stub.
|
||||
const stubVol = uint32(9479)
|
||||
framework.AllocateVolume(t, grpcClient, stubVol, "ec-stub")
|
||||
|
||||
stubTask := newSweepTaskForTest(server, stubVol, "ec-stub", dialOption)
|
||||
require.NoError(t, stubTask.sweepEmptyReplicas(ctx))
|
||||
|
||||
require.True(t, stubTask.emptyReplicasDeleted[server], "stub server should be recorded as swept")
|
||||
_, err := grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: stubVol})
|
||||
require.Error(t, err, "empty stub volume must be deleted by the sweep")
|
||||
|
||||
// A volume holding data must survive the sweep.
|
||||
const dataVol = uint32(9480)
|
||||
framework.AllocateVolume(t, grpcClient, dataVol, "ec-data")
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(dataVol, 948000, 0x9480CAFE)
|
||||
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("real-data-keep-me"))
|
||||
_ = framework.ReadAllAndClose(t, upResp)
|
||||
require.Equal(t, http.StatusCreated, upResp.StatusCode)
|
||||
|
||||
dataTask := newSweepTaskForTest(server, dataVol, "ec-data", dialOption)
|
||||
require.NoError(t, dataTask.sweepEmptyReplicas(ctx), "a not-empty refusal is expected, not an error")
|
||||
|
||||
require.False(t, dataTask.emptyReplicasDeleted[server], "data-bearing server must not be recorded as swept")
|
||||
_, err = grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: dataVol})
|
||||
require.NoError(t, err, "data-bearing volume must survive the sweep")
|
||||
}
|
||||
|
||||
func newSweepTaskForTest(server string, volumeID uint32, collection string, dialOption grpc.DialOption) *ErasureCodingTask {
|
||||
task := NewErasureCodingTask("sweep-"+collection, server, volumeID, collection, dialOption)
|
||||
task.sources = []*worker_pb.TaskSource{{Node: server, VolumeId: volumeID}}
|
||||
return task
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// End-to-end on two servers: a volume with a real replica on server A and a
|
||||
// 0-byte stub replica of the same id on server B (an interrupted-encode
|
||||
// leftover). After a full EC encode the cluster must end in exactly one valid
|
||||
// layout — the complete shard set split across A and B, each with .ecx/.vif —
|
||||
// and every wrong file must be gone: both regular .dat files (source deleted
|
||||
// after verify, stub swept before distribute), no shard on the wrong server,
|
||||
// and B's shared <collection>_<vid>.vif intact rather than clobbered by the
|
||||
// stub's delete.
|
||||
func TestEcEncodeLeavesRightFilesAndRemovesStubAndSource(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster := framework.StartMultiVolumeCluster(t, matrix.P1(), 2)
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
const (
|
||||
volumeID = uint32(9490)
|
||||
collection = "ec-e2e"
|
||||
)
|
||||
addrA := serverAddress(cluster, 0)
|
||||
addrB := serverAddress(cluster, 1)
|
||||
|
||||
connA, clientA := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
|
||||
defer connA.Close()
|
||||
connB, clientB := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
|
||||
defer connB.Close()
|
||||
|
||||
// Server A: real source replica with data.
|
||||
framework.AllocateVolume(t, clientA, volumeID, collection)
|
||||
httpClient := framework.NewHTTPClient()
|
||||
for i := 0; i < 8; i++ {
|
||||
fid := framework.NewFileID(volumeID, uint64(948000+i), 0x9490CA00+uint32(i))
|
||||
payload := make([]byte, 4096)
|
||||
for j := range payload {
|
||||
payload[j] = byte(i + 1)
|
||||
}
|
||||
resp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(0), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Server B: empty stub replica of the same volume id.
|
||||
framework.AllocateVolume(t, clientB, volumeID, collection)
|
||||
|
||||
dataShards := int(erasure_coding.DataShardsCount)
|
||||
totalShards := int(erasure_coding.DataShardsCount + erasure_coding.ParityShardsCount)
|
||||
aShards := shardRange(0, dataShards) // 0..DataShardsCount-1 on A
|
||||
bShards := shardRange(dataShards, totalShards) // parity range on B
|
||||
|
||||
task := NewErasureCodingTask("ec-e2e", addrA, volumeID, collection, dialOption)
|
||||
params := &worker_pb.TaskParams{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{Node: addrA, VolumeId: volumeID},
|
||||
{Node: addrB, VolumeId: volumeID},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{Node: addrA, ShardIds: aShards},
|
||||
{Node: addrB, ShardIds: bShards},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: erasure_coding.DataShardsCount,
|
||||
ParityShards: erasure_coding.ParityShardsCount,
|
||||
WorkingDir: t.TempDir(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, task.Execute(ctx, params))
|
||||
|
||||
dirA := filepath.Join(cluster.BaseDir(), "volume0")
|
||||
dirB := filepath.Join(cluster.BaseDir(), "volume1")
|
||||
base := fmt.Sprintf("%s_%d", collection, volumeID)
|
||||
|
||||
// Both original regular volumes are gone: A's source deleted after verify,
|
||||
// B's stub swept before distribute.
|
||||
requireAbsent(t, dirA, base+".dat")
|
||||
requireAbsent(t, dirA, base+".idx")
|
||||
requireAbsent(t, dirB, base+".dat")
|
||||
requireAbsent(t, dirB, base+".idx")
|
||||
|
||||
// Each server holds exactly its assigned shards, plus index/info sidecars.
|
||||
for _, id := range aShards {
|
||||
requirePresent(t, dirA, fmt.Sprintf("%s.ec%02d", base, id))
|
||||
}
|
||||
for _, id := range bShards {
|
||||
requireAbsent(t, dirA, fmt.Sprintf("%s.ec%02d", base, id))
|
||||
}
|
||||
requirePresent(t, dirA, base+".ecx")
|
||||
requirePresent(t, dirA, base+".vif")
|
||||
|
||||
for _, id := range bShards {
|
||||
requirePresent(t, dirB, fmt.Sprintf("%s.ec%02d", base, id))
|
||||
}
|
||||
for _, id := range aShards {
|
||||
requireAbsent(t, dirB, fmt.Sprintf("%s.ec%02d", base, id))
|
||||
}
|
||||
requirePresent(t, dirB, base+".ecx")
|
||||
// The shared .vif must survive on B: the stub was deleted before the EC
|
||||
// files landed, so deleteOriginalVolume never ran removeVolumeFiles there.
|
||||
requirePresent(t, dirB, base+".vif")
|
||||
}
|
||||
|
||||
// serverAddress builds the SeaweedFS ip:httpPort.grpcPort address the worker's
|
||||
// gRPC client decodes, from the multi-cluster's separate admin and grpc ports.
|
||||
func serverAddress(c *framework.MultiVolumeCluster, index int) string {
|
||||
_, grpcPort, err := net.SplitHostPort(c.VolumeGRPCAddress(index))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c.VolumeAdminAddress(index) + "." + grpcPort
|
||||
}
|
||||
|
||||
func shardRange(start, end int) []uint32 {
|
||||
ids := make([]uint32, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
ids = append(ids, uint32(i))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func requirePresent(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(dir, name))
|
||||
require.NoError(t, err, "expected %s to be present in %s", name, dir)
|
||||
}
|
||||
|
||||
func requireAbsent(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(dir, name))
|
||||
require.True(t, os.IsNotExist(err), "expected %s to be absent in %s, stat err=%v", name, dir, err)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Reproduces the issue-9478 volume-13 layout end to end on a multi-server,
|
||||
// multi-disk cluster and asserts the cluster converges to exactly one valid EC
|
||||
// layout. Before the encode the volume is in the stuck state: a real .dat
|
||||
// source, a 0-byte stub replica left by an interrupted encode, and partial
|
||||
// stale EC shards from that attempt. After the worker runs (sweep stubs ->
|
||||
// clear stale shards -> distribute -> mount -> verify -> delete originals):
|
||||
//
|
||||
// - every assigned shard is present on its target disk, with .ecx/.vif
|
||||
// alongside it (the EC volume's info file survives even when shards land on
|
||||
// the source's own disk);
|
||||
// - no regular .dat/.idx remains anywhere — the source is deleted after
|
||||
// verify, the stub was swept before distribute;
|
||||
// - the stale shards that aren't part of the new assignment are gone.
|
||||
func TestEcEncodeJulorLayoutConverges(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
const disksPerServer = 2
|
||||
cluster := framework.StartMultiVolumeClusterWithDisks(t, matrix.P1(), 3, disksPerServer)
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
const (
|
||||
volumeID = uint32(9500)
|
||||
collection = "ec-julor"
|
||||
)
|
||||
// S0: real source. S1: 0-byte stub + EC target. S2: pure EC target.
|
||||
const (
|
||||
srcServer = 0
|
||||
stubServer = 1
|
||||
)
|
||||
addr := func(i int) string { return serverAddress(cluster, i) }
|
||||
|
||||
conns := make([]*grpc.ClientConn, 3)
|
||||
clients := make([]volume_server_pb.VolumeServerClient, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
conns[i], clients[i] = framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(i))
|
||||
defer conns[i].Close()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// S0: real source replica with data.
|
||||
framework.AllocateVolume(t, clients[srcServer], volumeID, collection)
|
||||
httpClient := framework.NewHTTPClient()
|
||||
for i := 0; i < 8; i++ {
|
||||
fid := framework.NewFileID(volumeID, uint64(950000+i), 0x9500CA00+uint32(i))
|
||||
payload := make([]byte, 4096)
|
||||
for j := range payload {
|
||||
payload[j] = byte(i + 1)
|
||||
}
|
||||
resp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(srcServer), fid, payload)
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Partial stale EC shards from a previous interrupted encode, mounted on the
|
||||
// source. cleanupStaleEcShards must clear them before re-distribute.
|
||||
_, err := clients[srcServer].VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
staleShards := []uint32{11, 12, 13}
|
||||
_, err = clients[srcServer].VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID, Collection: collection, ShardIds: staleShards,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// S1: a 0-byte stub replica of the same volume (interrupted-encode leftover).
|
||||
framework.AllocateVolume(t, clients[stubServer], volumeID, collection)
|
||||
|
||||
// Plan: 14 shards spread across all three servers and both disks. The source
|
||||
// server (S0) receives shards too, so its regular volume coexists with EC on
|
||||
// the same disk before deletion.
|
||||
targets := []*worker_pb.TaskTarget{
|
||||
{Node: addr(0), DiskId: 0, ShardIds: shardRange(0, 3)}, // S0 d0: 0,1,2
|
||||
{Node: addr(0), DiskId: 1, ShardIds: shardRange(3, 5)}, // S0 d1: 3,4
|
||||
{Node: addr(1), DiskId: 0, ShardIds: shardRange(5, 8)}, // S1 d0: 5,6,7
|
||||
{Node: addr(1), DiskId: 1, ShardIds: shardRange(8, 10)}, // S1 d1: 8,9
|
||||
{Node: addr(2), DiskId: 0, ShardIds: shardRange(10, 12)}, // S2 d0: 10,11
|
||||
{Node: addr(2), DiskId: 1, ShardIds: shardRange(12, 14)}, // S2 d1: 12,13
|
||||
}
|
||||
|
||||
task := NewErasureCodingTask("ec-julor", addr(srcServer), volumeID, collection, dialOption)
|
||||
params := &worker_pb.TaskParams{
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{Node: addr(srcServer), VolumeId: volumeID}, // real source
|
||||
{Node: addr(stubServer), VolumeId: volumeID}, // 0-byte stub
|
||||
{Node: addr(srcServer), VolumeId: volumeID, ShardIds: staleShards}, // stale EC shards to clear
|
||||
},
|
||||
Targets: targets,
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: erasure_coding.DataShardsCount,
|
||||
ParityShards: erasure_coding.ParityShardsCount,
|
||||
WorkingDir: t.TempDir(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, task.Execute(ctx, params))
|
||||
|
||||
base := fmt.Sprintf("%s_%d", collection, volumeID)
|
||||
|
||||
// Shards are distributed per-server; the disk within a server is auto-selected
|
||||
// when DiskId is 0, and on a multi-disk server shards can spread to a sibling
|
||||
// disk while the .ecx/.vif index lives on one of them (the cross-disk EC
|
||||
// layout). So assert per server (aggregate across its disks): each server
|
||||
// holds exactly its assigned shards, its .ecx/.vif survive on at least one
|
||||
// disk (the info file is not stripped, even on the source's own disk), and no
|
||||
// regular .dat/.idx remains. Stale shards 11-13 mounted on the source must be
|
||||
// gone — they are not in S0's assignment.
|
||||
serverShards := map[int][]uint32{
|
||||
0: shardRange(0, 5), // S0 (source): 0..4
|
||||
1: shardRange(5, 10), // S1 (stub): 5..9
|
||||
2: shardRange(10, 14), // S2 (target): 10..13
|
||||
}
|
||||
allShards := map[uint32]bool{}
|
||||
for server, want := range serverShards {
|
||||
found := map[uint32]bool{}
|
||||
hasEcx, hasVif := false, false
|
||||
for d := 0; d < disksPerServer; d++ {
|
||||
dir := cluster.VolumeDiskDir(server, d)
|
||||
requireAbsent(t, dir, base+".dat")
|
||||
requireAbsent(t, dir, base+".idx")
|
||||
for id := 0; id < erasure_coding.TotalShardsCount; id++ {
|
||||
if ecFileExists(dir, fmt.Sprintf("%s.ec%02d", base, id)) {
|
||||
found[uint32(id)] = true
|
||||
allShards[uint32(id)] = true
|
||||
}
|
||||
}
|
||||
hasEcx = hasEcx || ecFileExists(dir, base+".ecx")
|
||||
hasVif = hasVif || ecFileExists(dir, base+".vif")
|
||||
}
|
||||
gotIDs := make([]uint32, 0, len(found))
|
||||
for id := range found {
|
||||
gotIDs = append(gotIDs, id)
|
||||
}
|
||||
require.ElementsMatch(t, want, gotIDs, "server %d should hold exactly its assigned shards", server)
|
||||
require.True(t, hasEcx, "server %d must keep its .ecx", server)
|
||||
require.True(t, hasVif, "server %d must keep its .vif (not stripped by the original-volume delete)", server)
|
||||
}
|
||||
require.Len(t, allShards, erasure_coding.TotalShardsCount, "the full shard set must be present across the cluster")
|
||||
}
|
||||
|
||||
func ecFileExists(dir, name string) bool {
|
||||
_, err := os.Stat(filepath.Join(dir, name))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// cleanupOrphanSourceReplicas must not delete a source replica that has come
|
||||
// back writable: it may hold writes the EC shards do not contain. A still
|
||||
// readonly replica is the genuine orphan and is deleted.
|
||||
func TestCleanupOrphanSkipsWritableSourceReplica(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(955)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
clusterInfo := &types.ClusterInfo{
|
||||
ActiveTopology: buildSingleNodeTopology(t, clusterHarness.VolumeServerAddress(), volumeID),
|
||||
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
}
|
||||
metric := &types.VolumeHealthMetrics{VolumeID: volumeID, Collection: ""}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Writable source replica: must be left intact.
|
||||
deleted, err := cleanupOrphanSourceReplicas(ctx, clusterInfo, metric, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, deleted, "a writable source replica must not be deleted")
|
||||
require.True(t, volumeExists(t, grpcClient, volumeID), "writable source volume must still exist")
|
||||
|
||||
// Mark readonly: now it is a genuine orphan and must be deleted.
|
||||
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: volumeID})
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted, err = cleanupOrphanSourceReplicas(ctx, clusterInfo, metric, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, deleted, "a readonly orphan source replica must be deleted")
|
||||
require.False(t, volumeExists(t, grpcClient, volumeID), "readonly orphan source volume must be deleted")
|
||||
}
|
||||
|
||||
func volumeExists(t *testing.T, client volume_server_pb.VolumeServerClient, volumeID uint32) bool {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// buildSingleNodeTopology builds an ActiveTopology with one node (at serverAddr)
|
||||
// holding the regular volume, so cleanupOrphanSourceReplicas resolves the
|
||||
// replica to the live test server and its gRPC calls reach it.
|
||||
func buildSingleNodeTopology(t *testing.T, serverAddr string, volumeID uint32) *topology.ActiveTopology {
|
||||
t.Helper()
|
||||
at := topology.NewActiveTopology(10)
|
||||
topologyInfo := &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{{
|
||||
Id: serverAddr,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{
|
||||
{Id: volumeID, Collection: "", DiskId: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
require.NoError(t, at.UpdateTopology(topologyInfo))
|
||||
return at
|
||||
}
|
||||
231
weed/worker/tasks/erasure_coding/ec_task_preflight_test.go
Normal file
231
weed/worker/tasks/erasure_coding/ec_task_preflight_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Reproduces the swtest interrupted-encode scenario at the task boundary: a
|
||||
// previous encode of this volume was cut mid-distribute, leaving partial EC
|
||||
// shards mounted on a destination. When a fresh encode task starts, its Step 0
|
||||
// preflight (ensureCleanEcStart) must clear those shards before any
|
||||
// destructive step, so distribute's ReceiveFile is not refused and no orphan
|
||||
// shards survive to confuse the volume-server loader or make detection refuse
|
||||
// the volume. The destination is named only as a target (no source row) to
|
||||
// prove the preflight reaches the full write set, not just shard-bearing
|
||||
// sources.
|
||||
func TestEnsureCleanEcStartClearsStaleShardsBeforeEncode(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const (
|
||||
volumeID = uint32(94782)
|
||||
collection = "ec-preflight-invariant"
|
||||
)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 9478200, 0x9478BEEF)
|
||||
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("payload-for-preflight-stale-ec-cleanup"))
|
||||
_ = framework.ReadAllAndClose(t, upResp)
|
||||
require.Equal(t, http.StatusCreated, upResp.StatusCode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A half-finished previous distribute left a partial shard set mounted.
|
||||
staleShards := []uint32{0, 1, 2}
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
ShardIds: staleShards,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
shardPath := makeTinyEcShardFile(t)
|
||||
|
||||
// Precondition for the reproduction: the mounted partial EC blocks a fresh
|
||||
// ReceiveFile via the mounted-volume guard.
|
||||
err = sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath)
|
||||
require.Error(t, err, "expected ReceiveFile to be refused while stale EC volume is mounted")
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"preflight-invariant",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
collection,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
// Named only as a target — no source row for this node. The preflight must
|
||||
// still clear its stale shards.
|
||||
task.targets = []*worker_pb.TaskTarget{
|
||||
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID, ShardIds: []uint32{0}},
|
||||
}
|
||||
|
||||
require.NoError(t, task.ensureCleanEcStart(ctx),
|
||||
"Step 0 preflight must clear stale EC shards at task start")
|
||||
|
||||
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
|
||||
require.Error(t, infoErr, "stale EC volume must be gone after the preflight")
|
||||
|
||||
require.NoError(t,
|
||||
sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath),
|
||||
"ReceiveFile must succeed once the preflight has cleared the stale shards")
|
||||
}
|
||||
|
||||
// A post-distribute failure that has no successor to clean up after it (a
|
||||
// single-attempt job, or the last of a retry series) must leave nothing behind:
|
||||
// rollbackDistribute tears down the shards this attempt wrote while leaving the
|
||||
// source volume intact for a future re-encode. Reproduces the swtest finding
|
||||
// that a terminally-failed encode stranded orphan shards.
|
||||
func TestRollbackDistributeClearsShardsAndKeepsSource(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const (
|
||||
volumeID = uint32(94783)
|
||||
collection = "ec-rollback-distribute"
|
||||
)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 9478300, 0x9478D00D)
|
||||
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("payload-for-rollback-distribute"))
|
||||
_ = framework.ReadAllAndClose(t, upResp)
|
||||
require.Equal(t, http.StatusCreated, upResp.StatusCode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Shards this attempt "distributed" and mounted on the destination.
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
ShardIds: []uint32{0, 1, 2, 3},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"rollback-distribute",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
collection,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.targets = []*worker_pb.TaskTarget{
|
||||
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID, ShardIds: []uint32{0}},
|
||||
}
|
||||
|
||||
// Simulate the failure path after distribute began.
|
||||
task.rollbackDistribute(ctx)
|
||||
|
||||
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
|
||||
require.Error(t, infoErr, "rollback must tear down the shards this attempt distributed")
|
||||
|
||||
// The source normal volume must survive so a future re-encode can proceed.
|
||||
_, statusErr := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
require.NoError(t, statusErr, "rollback must leave the source volume intact")
|
||||
}
|
||||
|
||||
// A malformed plan (no targets) must be rejected by the preflight before the
|
||||
// source is marked readonly or copied, so a bad plan cannot leave the source
|
||||
// fenced readonly with nothing to show for it. Pure precondition check: no
|
||||
// cluster and no RPC.
|
||||
func TestEnsureCleanEcStartRejectsMissingTargets(t *testing.T) {
|
||||
task := NewErasureCodingTask(
|
||||
"preflight-no-targets",
|
||||
"10.0.0.1:8080",
|
||||
42,
|
||||
"c",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
// No targets set.
|
||||
err := task.ensureCleanEcStart(context.Background())
|
||||
require.Error(t, err, "preflight must reject an encode with no shard targets")
|
||||
require.True(t, strings.Contains(err.Error(), "no EC shard targets"),
|
||||
"error must name the missing-targets invariant, got: %v", err)
|
||||
}
|
||||
|
||||
// A non-empty target slice whose entries are malformed (nil, empty Node, or no
|
||||
// assigned shards) must also be rejected before the source is marked readonly —
|
||||
// cleanupStaleEcShards silently skips such entries, so a length check alone
|
||||
// would let the encode proceed with nothing to distribute to.
|
||||
func TestEnsureCleanEcStartRejectsMalformedTarget(t *testing.T) {
|
||||
cases := map[string][]*worker_pb.TaskTarget{
|
||||
"nil target": {nil},
|
||||
"empty node": {{Node: "", VolumeId: 42, ShardIds: []uint32{0}}},
|
||||
"no shards": {{Node: "10.0.0.2:8080", VolumeId: 42}},
|
||||
"one good one bad": {
|
||||
{Node: "10.0.0.2:8080", VolumeId: 42, ShardIds: []uint32{0}},
|
||||
{Node: "", VolumeId: 42, ShardIds: []uint32{1}},
|
||||
},
|
||||
}
|
||||
for name, targets := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
task := NewErasureCodingTask(
|
||||
"preflight-malformed-target",
|
||||
"10.0.0.1:8080",
|
||||
42,
|
||||
"c",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.targets = targets
|
||||
err := task.ensureCleanEcStart(context.Background())
|
||||
require.Error(t, err, "preflight must reject a malformed target")
|
||||
require.True(t, strings.Contains(err.Error(), "malformed EC shard target"),
|
||||
"error must name the malformed-target invariant, got: %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A plan with targets but no source replica and no assigned server must be
|
||||
// rejected before any destructive step.
|
||||
func TestEnsureCleanEcStartRejectsMissingSource(t *testing.T) {
|
||||
task := NewErasureCodingTask(
|
||||
"preflight-no-source",
|
||||
"", // no assigned server
|
||||
42,
|
||||
"c",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.targets = []*worker_pb.TaskTarget{
|
||||
{Node: "10.0.0.2:8080", VolumeId: 42, ShardIds: []uint32{0}},
|
||||
}
|
||||
// No sources and no server.
|
||||
err := task.ensureCleanEcStart(context.Background())
|
||||
require.Error(t, err, "preflight must reject an encode with no source replica")
|
||||
require.True(t, strings.Contains(err.Error(), "no source replica"),
|
||||
"error must name the missing-source invariant, got: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Reproduces a stuck re-encode: partial EC shards mounted on a destination
|
||||
// from a previous failed encode cause ReceiveFile to refuse with the
|
||||
// mounted-volume guard. cleanupStaleEcShards must clear them so the next
|
||||
// ReceiveFile lands.
|
||||
func TestCleanupStaleEcShardsBeforeDistribute(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const (
|
||||
volumeID = uint32(9478)
|
||||
collection = "ec-9478-xserver"
|
||||
)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 947800, 0x9478CAFE)
|
||||
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("payload-for-cross-server-stale-ec-cleanup"))
|
||||
_ = framework.ReadAllAndClose(t, upResp)
|
||||
require.Equal(t, http.StatusCreated, upResp.StatusCode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Partial subset mimics a half-finished previous distribute: shards
|
||||
// mounted on the destination with no .dat to anchor a same-store prune.
|
||||
staleShards := []uint32{0, 1, 2}
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
ShardIds: staleShards,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
shardPath := makeTinyEcShardFile(t)
|
||||
|
||||
// Pre-cleanup: the mounted partial EC blocks ReceiveFile.
|
||||
err = sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath)
|
||||
require.Error(t, err, "expected ReceiveFile to be refused while EC volume is mounted")
|
||||
require.True(t,
|
||||
strings.Contains(err.Error(), "is mounted") ||
|
||||
strings.Contains(err.Error(), "unmount before ReceiveFile"),
|
||||
"expected refusal to name the mounted-volume guard, got: %v", err)
|
||||
|
||||
// ShardIds set marks this as an EC-shard cleanup source: cleanup will
|
||||
// target it; getReplicas must skip it.
|
||||
task := NewErasureCodingTask(
|
||||
"stale-ec-xserver",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
collection,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.dataShards = erasure_coding.DataShardsCount
|
||||
task.parityShards = erasure_coding.ParityShardsCount
|
||||
task.sources = []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: clusterHarness.VolumeServerAddress(),
|
||||
VolumeId: volumeID,
|
||||
ShardIds: staleShards,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, task.cleanupStaleEcShards(ctx))
|
||||
|
||||
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
|
||||
require.Error(t, infoErr, "EC volume should be gone after cleanupStaleEcShards")
|
||||
|
||||
require.NoError(t,
|
||||
sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 0, shardPath),
|
||||
"ReceiveFile must succeed after cleanup")
|
||||
|
||||
require.Empty(t, task.getReplicas(),
|
||||
"EC-shard sources must not appear in replica delete list")
|
||||
}
|
||||
|
||||
// The destination has more shards mounted than t.sources lists — simulates
|
||||
// detection-time topology missing shards (e.g., a prior attempt's mount
|
||||
// hadn't heartbeated yet, or different shards live on a sibling disk). The
|
||||
// cleanup must clear FindEcVolume regardless, by issuing unmount/delete
|
||||
// over the full shard range, so the next ReceiveFile lands.
|
||||
func TestCleanupStaleEcShardsClearsShardsBeyondSources(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const (
|
||||
volumeID = uint32(94780)
|
||||
collection = "ec-9478-beyond"
|
||||
)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 9478001, 0x9478FACE)
|
||||
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("payload-for-shards-beyond-sources"))
|
||||
_ = framework.ReadAllAndClose(t, upResp)
|
||||
require.Equal(t, http.StatusCreated, upResp.StatusCode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Five shards mounted on the destination.
|
||||
mountedShards := []uint32{0, 1, 2, 3, 4}
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
ShardIds: mountedShards,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Detection only "saw" two of them — the rest must still get cleared.
|
||||
knownToDetection := []uint32{0, 1}
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"stale-ec-beyond-sources",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
collection,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.dataShards = erasure_coding.DataShardsCount
|
||||
task.parityShards = erasure_coding.ParityShardsCount
|
||||
task.sources = []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: clusterHarness.VolumeServerAddress(),
|
||||
VolumeId: volumeID,
|
||||
ShardIds: knownToDetection,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, task.cleanupStaleEcShards(ctx))
|
||||
|
||||
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
|
||||
require.Error(t, infoErr,
|
||||
"all mounted shards must be cleared even though detection only listed a subset")
|
||||
|
||||
shardPath := makeTinyEcShardFile(t)
|
||||
require.NoError(t,
|
||||
sendShardViaReceiveFile(ctx, grpcClient, volumeID, collection, 4, shardPath),
|
||||
"ReceiveFile for a shard outside detection's snapshot must succeed after cleanup")
|
||||
}
|
||||
|
||||
// Cleanup also targets fresh destinations from t.targets even when no
|
||||
// EC-shard source row exists for them. This catches concurrent-attempt
|
||||
// fallout where a previous worker mounted shards on a node we're now
|
||||
// writing to but the topology snapshot is stale.
|
||||
func TestCleanupStaleEcShardsCoversTargetsWithoutSources(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const (
|
||||
volumeID = uint32(94781)
|
||||
collection = "ec-9478-targets-only"
|
||||
)
|
||||
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, collection)
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 9478101, 0x947811CE)
|
||||
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid,
|
||||
[]byte("payload-for-targets-only-cleanup"))
|
||||
_ = framework.ReadAllAndClose(t, upResp)
|
||||
require.Equal(t, http.StatusCreated, upResp.StatusCode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: volumeID, Collection: collection,
|
||||
ShardIds: []uint32{0, 1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"stale-ec-targets-only",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
collection,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.dataShards = erasure_coding.DataShardsCount
|
||||
task.parityShards = erasure_coding.ParityShardsCount
|
||||
// No sources. The destination is named only as a target — the cleanup
|
||||
// must still reach it.
|
||||
task.targets = []*worker_pb.TaskTarget{
|
||||
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID, ShardIds: []uint32{0}},
|
||||
}
|
||||
|
||||
require.NoError(t, task.cleanupStaleEcShards(ctx))
|
||||
|
||||
_, infoErr := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{VolumeId: volumeID})
|
||||
require.Error(t, infoErr,
|
||||
"target-only destinations must also be cleaned even without a source row")
|
||||
}
|
||||
|
||||
// Cleanup is a no-op when sources carry only the regular .dat replica.
|
||||
func TestCleanupStaleEcShardsSkipsRegularReplicas(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(9479)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"no-stale-ec",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
task.sources = []*worker_pb.TaskSource{
|
||||
{Node: clusterHarness.VolumeServerAddress(), VolumeId: volumeID},
|
||||
}
|
||||
|
||||
require.NoError(t, task.cleanupStaleEcShards(ctx))
|
||||
|
||||
_, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
|
||||
require.NoError(t, err, "regular volume must remain untouched")
|
||||
}
|
||||
|
||||
// makeTinyEcShardFile writes a placeholder payload — the mounted-volume
|
||||
// guard fires before any content is consumed, so the bytes don't need to
|
||||
// be a real shard.
|
||||
func makeTinyEcShardFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "shard.bin")
|
||||
require.NoError(t, os.WriteFile(p, []byte("ec-shard-placeholder"), 0o600))
|
||||
return p
|
||||
}
|
||||
|
||||
// sendShardViaReceiveFile streams a shard file through the same ReceiveFile
|
||||
// gRPC the EC worker uses, returning the server's reply error verbatim.
|
||||
func sendShardViaReceiveFile(
|
||||
ctx context.Context,
|
||||
client volume_server_pb.VolumeServerClient,
|
||||
volumeID uint32,
|
||||
collection string,
|
||||
shardID uint32,
|
||||
filePath string,
|
||||
) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stream, err := client.ReceiveFile(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_Info{
|
||||
Info: &volume_server_pb.ReceiveFileInfo{
|
||||
VolumeId: volumeID,
|
||||
Ext: erasure_coding.ToExt(int(shardID)),
|
||||
Collection: collection,
|
||||
IsEcVolume: true,
|
||||
ShardId: shardID,
|
||||
FileSize: uint64(info.Size()),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, readErr := f.Read(buf)
|
||||
if n > 0 {
|
||||
if err := stream.Send(&volume_server_pb.ReceiveFileRequest{
|
||||
Data: &volume_server_pb.ReceiveFileRequest_FileContent{
|
||||
FileContent: buf[:n],
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := stream.CloseAndRecv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return &receiveFileServerError{msg: resp.Error}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type receiveFileServerError struct{ msg string }
|
||||
|
||||
func (e *receiveFileServerError) Error() string { return e.msg }
|
||||
249
weed/worker/tasks/erasure_coding/ec_task_test.go
Normal file
249
weed/worker/tasks/erasure_coding/ec_task_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
|
||||
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/volume_info"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestCopyVolumeFilesToWorkerUsesCurrentCompactionRevision(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(951)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
|
||||
liveFID := framework.NewFileID(volumeID, 1001, 0x1111AAAA)
|
||||
liveUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), liveFID, []byte("live-payload-for-ec-copy"))
|
||||
_ = framework.ReadAllAndClose(t, liveUploadResp)
|
||||
require.Equal(t, http.StatusCreated, liveUploadResp.StatusCode)
|
||||
|
||||
deletedFID := framework.NewFileID(volumeID, 1002, 0x2222BBBB)
|
||||
deletedUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), deletedFID, []byte("deleted-payload-for-vacuum"))
|
||||
_ = framework.ReadAllAndClose(t, deletedUploadResp)
|
||||
require.Equal(t, http.StatusCreated, deletedUploadResp.StatusCode)
|
||||
|
||||
deleteReq, err := http.NewRequest(http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+deletedFID, nil)
|
||||
require.NoError(t, err)
|
||||
deleteResp := framework.DoRequest(t, httpClient, deleteReq)
|
||||
_ = framework.ReadAllAndClose(t, deleteResp)
|
||||
require.Equal(t, http.StatusAccepted, deleteResp.StatusCode)
|
||||
|
||||
compactVolumeOnce(t, grpcClient, volumeID)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"copy-after-compaction",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, task.markReplicasReadonly(ctx))
|
||||
|
||||
fileStatus, err := task.readSourceVolumeFileStatus(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, fileStatus.GetCompactionRevision(), uint32(0))
|
||||
|
||||
localFiles, err := task.copyVolumeFilesToWorker(ctx, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
datInfo, err := os.Stat(localFiles["dat"])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(fileStatus.GetDatFileSize()), datInfo.Size())
|
||||
|
||||
idxInfo, err := os.Stat(localFiles["idx"])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(fileStatus.GetIdxFileSize()), idxInfo.Size())
|
||||
}
|
||||
|
||||
// markReplicasReadonly must persist the readonly mark into the .vif so a
|
||||
// source-server restart cannot silently reopen the volume to writes that the
|
||||
// EC shards would not contain.
|
||||
func TestMarkReplicasReadonlyPersists(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(954)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 3001, 0x4444DDDD)
|
||||
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("payload-for-readonly-persist"))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"ec-readonly-persist",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, task.markReplicasReadonly(ctx))
|
||||
|
||||
vifPath := filepath.Join(clusterHarness.BaseDir(), "volume", fmt.Sprintf("%d.vif", volumeID))
|
||||
vi, _, found, err := volume_info.MaybeLoadVolumeInfo(vifPath)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found, "volume must have a .vif after a persisted readonly mark")
|
||||
require.True(t, vi.ReadOnly, "markReplicasReadonly must persist ReadOnly=true into the .vif")
|
||||
}
|
||||
|
||||
// The worker-local encode path must stamp the EC ratio and an encode identity
|
||||
// into the .vif, or the read guard is silently off for worker-encoded volumes.
|
||||
func TestGenerateEcShardsLocallyStampsEncodeIdentity(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(952)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 2001, 0x3333CCCC)
|
||||
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("payload-for-ec-encode-identity"))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"ec-encode-identity",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, task.markReplicasReadonly(ctx))
|
||||
localFiles, err := task.copyVolumeFilesToWorker(ctx, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
shardFiles, err := task.generateEcShardsLocally(localFiles, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
vifPath := shardFiles["vif"]
|
||||
require.NotEmpty(t, vifPath, "generate must produce a .vif")
|
||||
vi, _, found, err := volume_info.MaybeLoadVolumeInfo(vifPath)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.NotNil(t, vi.EcShardConfig, "worker .vif must record the EC config")
|
||||
require.Greater(t, vi.EcShardConfig.DataShards, uint32(0))
|
||||
require.Greater(t, vi.EcShardConfig.ParityShards, uint32(0))
|
||||
require.NotZero(t, vi.EcShardConfig.EncodeTsNs, "worker-encoded .vif must carry an encode identity")
|
||||
}
|
||||
|
||||
// A fenced run must stamp the admin-issued generation (t.encodeTsNs) verbatim into
|
||||
// the distributed .vif, so the generation the worker distributes equals the one it
|
||||
// later sends on the teardown RPCs and the volume-server fence compares like-for-like.
|
||||
func TestGenerateEcShardsLocallyUsesAdminGeneration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
clusterHarness := framework.StartVolumeCluster(t, matrix.P1())
|
||||
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
|
||||
defer conn.Close()
|
||||
|
||||
const volumeID = uint32(957)
|
||||
framework.AllocateVolume(t, grpcClient, volumeID, "")
|
||||
|
||||
httpClient := framework.NewHTTPClient()
|
||||
fid := framework.NewFileID(volumeID, 2002, 0x5555EEEE)
|
||||
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("payload-for-admin-generation"))
|
||||
_ = framework.ReadAllAndClose(t, resp)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
"ec-admin-generation",
|
||||
clusterHarness.VolumeServerAddress(),
|
||||
volumeID,
|
||||
"",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
const adminGeneration int64 = 1_700_000_000_000_000_321
|
||||
task.encodeTsNs = adminGeneration
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, task.markReplicasReadonly(ctx))
|
||||
localFiles, err := task.copyVolumeFilesToWorker(ctx, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
shardFiles, err := task.generateEcShardsLocally(localFiles, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
vi, _, found, err := volume_info.MaybeLoadVolumeInfo(shardFiles["vif"])
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.NotNil(t, vi.EcShardConfig)
|
||||
require.Equal(t, adminGeneration, vi.EcShardConfig.EncodeTsNs, "fenced run must stamp the admin generation verbatim, not a local timestamp")
|
||||
}
|
||||
|
||||
func compactVolumeOnce(t *testing.T, grpcClient volume_server_pb.VolumeServerClient, volumeID uint32) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
compactStream, err := grpcClient.VacuumVolumeCompact(ctx, &volume_server_pb.VacuumVolumeCompactRequest{
|
||||
VolumeId: volumeID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for {
|
||||
_, err = compactStream.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = grpcClient.VacuumVolumeCommit(ctx, &volume_server_pb.VacuumVolumeCommitRequest{
|
||||
VolumeId: volumeID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = grpcClient.VacuumVolumeCleanup(ctx, &volume_server_pb.VacuumVolumeCleanupRequest{
|
||||
VolumeId: volumeID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
43
weed/worker/tasks/erasure_coding/planning.go
Normal file
43
weed/worker/tasks/erasure_coding/planning.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// isStubReplica reports whether a regular replica's .dat is too small to hold
|
||||
// any data — at most a bare superblock. An interrupted encode or copy can
|
||||
// leave a 0-byte .dat, or write the 8-byte superblock and then fail; either
|
||||
// way the file is not an encode source and must not be counted toward
|
||||
// size/fullness checks.
|
||||
func isStubReplica(size uint64) bool {
|
||||
return size <= uint64(super_block.SuperBlockSize)
|
||||
}
|
||||
|
||||
// selectCanonicalMetric picks the metric that drives the encode checks and
|
||||
// names the encode source server. It prefers the lowest-server credible
|
||||
// replica — data-bearing and not already EC — so a 0-byte stub or a leftover
|
||||
// EC shard set sharing the volume id cannot become canonical and strand the
|
||||
// volume (a stub trips the min-size gate; an EC metric trips the IsECVolume
|
||||
// guard, hiding the volume from both orphan-source cleanup and re-encode).
|
||||
// When nothing is credible there is nothing to encode, so the lowest-server
|
||||
// metric is returned unchanged and the downstream gates make the skip
|
||||
// decision as before.
|
||||
func selectCanonicalMetric(group []*types.VolumeHealthMetrics) *types.VolumeHealthMetrics {
|
||||
var lowest, credible *types.VolumeHealthMetrics
|
||||
for _, m := range group {
|
||||
if lowest == nil || m.Server < lowest.Server {
|
||||
lowest = m
|
||||
}
|
||||
if m.IsECVolume || isStubReplica(m.Size) {
|
||||
continue
|
||||
}
|
||||
if credible == nil || m.Server < credible.Server {
|
||||
credible = m
|
||||
}
|
||||
}
|
||||
if credible != nil {
|
||||
return credible
|
||||
}
|
||||
return lowest
|
||||
}
|
||||
140
weed/worker/tasks/erasure_coding/planning_test.go
Normal file
140
weed/worker/tasks/erasure_coding/planning_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsStubReplica(t *testing.T) {
|
||||
assert.True(t, isStubReplica(0), "0-byte .dat is a stub")
|
||||
assert.True(t, isStubReplica(uint64(super_block.SuperBlockSize)-1), "below a superblock is a stub")
|
||||
assert.True(t, isStubReplica(uint64(super_block.SuperBlockSize)), "a bare superblock holds no data — a stub")
|
||||
assert.False(t, isStubReplica(uint64(super_block.SuperBlockSize)+1), "data beyond the superblock is a real replica")
|
||||
assert.False(t, isStubReplica(200*1024*1024), "a data-bearing replica is not a stub")
|
||||
}
|
||||
|
||||
// A 0-byte stub left by an interrupted encode often sorts to a lower server id
|
||||
// than the real replica. The old lowest-server canonical pick then reported
|
||||
// Size=0, tripped the min-size gate, and the volume was stranded in
|
||||
// skippedTooSmall forever. selectCanonicalMetric must skip the stub and return
|
||||
// the data-bearing replica.
|
||||
func TestSelectCanonicalMetricPrefersCredibleOverLowServerStub(t *testing.T) {
|
||||
stub := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.1:8080", Size: 0}
|
||||
real := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.4:8080", Size: 200 * 1024 * 1024}
|
||||
|
||||
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{stub, real})
|
||||
require.Same(t, real, got)
|
||||
}
|
||||
|
||||
// An EC-side metric (the partial shards from a failed encode) can also sort
|
||||
// below the regular replica. Picking it would short-circuit at the
|
||||
// IsECVolume guard and skip the volume, hiding it from both the orphan-source
|
||||
// cleanup and the re-encode path. The credible regular replica must win.
|
||||
func TestSelectCanonicalMetricSkipsECMetrics(t *testing.T) {
|
||||
ecMetric := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.1:8080", Size: 50 * 1024 * 1024, IsECVolume: true}
|
||||
real := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.4:8080", Size: 200 * 1024 * 1024}
|
||||
|
||||
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{ecMetric, real})
|
||||
require.Same(t, real, got)
|
||||
}
|
||||
|
||||
// When nothing is credible there is nothing to encode. Fall back to the
|
||||
// lowest-server metric so the downstream gates (min-size / IsECVolume) make
|
||||
// the skip decision exactly as before — selectCanonicalMetric must not invent
|
||||
// a source.
|
||||
func TestSelectCanonicalMetricAllStubsFallsBackToLowestServer(t *testing.T) {
|
||||
stubHi := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.4:8080", Size: 0}
|
||||
stubLo := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.1:8080", Size: 0}
|
||||
|
||||
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{stubHi, stubLo})
|
||||
require.Same(t, stubLo, got)
|
||||
}
|
||||
|
||||
// Among several credible replicas the lowest server id still wins, preserving
|
||||
// the deterministic canonical choice the task-dedup logic relies on.
|
||||
func TestSelectCanonicalMetricTieBreaksByServerAmongCredible(t *testing.T) {
|
||||
hi := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.7:8080", Size: 200 * 1024 * 1024}
|
||||
lo := &types.VolumeHealthMetrics{VolumeID: 13, Server: "10.0.0.2:8080", Size: 200 * 1024 * 1024}
|
||||
|
||||
got := selectCanonicalMetric([]*types.VolumeHealthMetrics{hi, lo})
|
||||
require.Same(t, lo, got)
|
||||
}
|
||||
|
||||
func TestSelectCanonicalMetricEmpty(t *testing.T) {
|
||||
require.Nil(t, selectCanonicalMetric(nil))
|
||||
require.Nil(t, selectCanonicalMetric([]*types.VolumeHealthMetrics{}))
|
||||
}
|
||||
|
||||
// End-to-end regression for the stranded-volume bug: a volume whose lowest
|
||||
// server holds a 0-byte stub and whose real replica is on a higher server must
|
||||
// still be proposed for EC encoding, not silently dropped as too-small.
|
||||
func TestDetectionEncodesDespiteLowServerStub(t *testing.T) {
|
||||
const volumeID uint32 = 13
|
||||
activeTopology := buildStubReplicaTopology(t, volumeID)
|
||||
clusterInfo := &types.ClusterInfo{ActiveTopology: activeTopology}
|
||||
|
||||
lastModified := time.Now().Add(-2 * time.Hour)
|
||||
metrics := []*types.VolumeHealthMetrics{
|
||||
// Stub on the lowest server: an interrupted encode left a 0-byte .dat.
|
||||
{VolumeID: volumeID, Server: "127.0.0.1:8080", Size: 0, FullnessRatio: 0, LastModified: lastModified, Age: time.Since(lastModified)},
|
||||
// The real replica on a higher server.
|
||||
{VolumeID: volumeID, Server: "127.0.0.1:8081", Size: 200 * 1024 * 1024, FullnessRatio: 0.96, LastModified: lastModified, Age: time.Since(lastModified)},
|
||||
}
|
||||
|
||||
results, _, err := Detection(context.Background(), metrics, clusterInfo, NewDefaultConfig(), 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1, "volume with a real replica must be proposed for EC despite a low-server stub")
|
||||
assert.Equal(t, types.TaskTypeErasureCoding, results[0].TaskType)
|
||||
assert.Equal(t, volumeID, results[0].VolumeID)
|
||||
}
|
||||
|
||||
// buildStubReplicaTopology builds a cluster with TotalShardsCount single-disk
|
||||
// nodes (enough targets to place every shard) where node 0 holds a 0-byte stub
|
||||
// replica of volumeID and node 1 holds the real replica.
|
||||
func buildStubReplicaTopology(t *testing.T, volumeID uint32) *topology.ActiveTopology {
|
||||
t.Helper()
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
nodes := make([]*master_pb.DataNodeInfo, 0, erasure_coding.TotalShardsCount)
|
||||
for i := 0; i < erasure_coding.TotalShardsCount; i++ {
|
||||
diskInfo := &master_pb.DiskInfo{
|
||||
DiskId: 0,
|
||||
MaxVolumeCount: 200,
|
||||
}
|
||||
switch i {
|
||||
case 0:
|
||||
diskInfo.VolumeInfos = []*master_pb.VolumeInformationMessage{{
|
||||
Id: volumeID, DiskId: 0, DiskType: "hdd", Size: 0,
|
||||
}}
|
||||
diskInfo.VolumeCount = 1
|
||||
case 1:
|
||||
diskInfo.VolumeInfos = []*master_pb.VolumeInformationMessage{{
|
||||
Id: volumeID, DiskId: 0, DiskType: "hdd", Size: 200 * 1024 * 1024,
|
||||
}}
|
||||
diskInfo.VolumeCount = 1
|
||||
}
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: fmt.Sprintf("127.0.0.1:%d", 8080+i),
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": diskInfo},
|
||||
})
|
||||
}
|
||||
require.NoError(t, activeTopology.UpdateTopology(&master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: nodes,
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
return activeTopology
|
||||
}
|
||||
878
weed/worker/tasks/erasure_coding/plugin_handler.go
Normal file
878
weed/worker/tasks/erasure_coding/plugin_handler.go
Normal file
@@ -0,0 +1,878 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
||||
JobType: "erasure_coding",
|
||||
Category: pluginworker.CategoryHeavy,
|
||||
Aliases: []string{"erasure-coding", "erasure.coding", "ec"},
|
||||
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
||||
return NewErasureCodingHandler(opts.GrpcDialOption, opts.WorkingDir), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type erasureCodingWorkerConfig struct {
|
||||
TaskConfig *Config
|
||||
}
|
||||
|
||||
// ErasureCodingHandler is the plugin job handler for erasure coding.
|
||||
type ErasureCodingHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewErasureCodingHandler(grpcDialOption grpc.DialOption, workingDir string) *ErasureCodingHandler {
|
||||
return &ErasureCodingHandler{grpcDialOption: grpcDialOption, workingDir: strings.TrimSpace(workingDir)}
|
||||
}
|
||||
|
||||
func (h *ErasureCodingHandler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: "erasure_coding",
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 1,
|
||||
DisplayName: "EC Encoding",
|
||||
Description: "Converts full and quiet volumes into EC shards",
|
||||
Weight: 80,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ErasureCodingHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: "erasure_coding",
|
||||
DisplayName: "EC Encoding",
|
||||
Description: "Detect and execute erasure coding for suitable volumes",
|
||||
Icon: "fas fa-shield-alt",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "erasure-coding-admin",
|
||||
Title: "Erasure Coding Admin Config",
|
||||
Description: "Admin-side controls for erasure coding detection scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Optional filters applied before erasure coding detection.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only erasure code volumes in matching collections. Comma-separated list of names, wildcards, or regex patterns.",
|
||||
Placeholder: "all collections",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"collection_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "erasure-coding-worker",
|
||||
Title: "Erasure Coding Worker Config",
|
||||
Description: "Worker-side detection thresholds.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "thresholds",
|
||||
Title: "Detection Thresholds",
|
||||
Description: "Controls for when erasure coding jobs should be proposed.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "quiet_for_seconds",
|
||||
Label: "Quiet Period (s)",
|
||||
Description: "Volume must remain unmodified for at least this duration.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: "fullness_ratio",
|
||||
Label: "Fullness Ratio",
|
||||
Description: "Minimum volume fullness ratio to trigger erasure coding.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 1}},
|
||||
},
|
||||
{
|
||||
Name: "min_size_mb",
|
||||
Label: "Minimum Volume Size (MB)",
|
||||
Description: "Only volumes larger than this size are considered.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "preferred_tags",
|
||||
Label: "Preferred Tags",
|
||||
Description: "Comma-separated disk tags to prioritize for EC shard placement, ordered by preference.",
|
||||
Placeholder: "fast,ssd",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "replica_placement",
|
||||
Label: "Replica Placement",
|
||||
Description: "EC shard placement (e.g. 020): 2nd/3rd digits cap shards per rack/node (best-effort during encode, enforced by rebalancing); the data-center digit is ignored. Empty uses the master default.",
|
||||
Placeholder: "020",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"quiet_for_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3600},
|
||||
},
|
||||
"fullness_ratio": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.95},
|
||||
},
|
||||
"min_size_mb": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 30},
|
||||
},
|
||||
"preferred_tags": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"replica_placement": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalMinutes: 17,
|
||||
DetectionTimeoutSeconds: 300,
|
||||
MaxJobsPerDetection: 500,
|
||||
GlobalExecutionConcurrency: 16,
|
||||
PerWorkerExecutionConcurrency: 4,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 30,
|
||||
JobTypeMaxRuntimeSeconds: 1800,
|
||||
ExecutionTimeoutSeconds: 1800,
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"quiet_for_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3600},
|
||||
},
|
||||
"fullness_ratio": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.95},
|
||||
},
|
||||
"min_size_mb": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 30},
|
||||
},
|
||||
"preferred_tags": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ErasureCodingHandler) Detect(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.RunDetectionRequest,
|
||||
sender pluginworker.DetectionSender,
|
||||
) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != "erasure_coding" {
|
||||
return fmt.Errorf("job type %q is not handled by erasure_coding worker", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := deriveErasureCodingWorkerConfig(request.GetWorkerConfigValues())
|
||||
|
||||
collectionFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
|
||||
if collectionFilter != "" {
|
||||
workerConfig.TaskConfig.CollectionFilter = collectionFilter
|
||||
}
|
||||
|
||||
masters := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{
|
||||
ActiveTopology: activeTopology,
|
||||
GrpcDialOption: h.grpcDialOption,
|
||||
DefaultReplicaPlacement: pluginworker.FetchDefaultReplicaPlacement(ctx, masters, h.grpcDialOption),
|
||||
}
|
||||
maxResults := int(request.MaxResults)
|
||||
if maxResults < 0 {
|
||||
maxResults = 0
|
||||
}
|
||||
results, hasMore, err := Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Stamp the admin-issued encode generation onto every EC proposal. DetectionSequence
|
||||
// is minted once per cycle on the single admin clock, so generations are globally
|
||||
// ordered even though detection runs on a rotating worker; this lets a stale worker's
|
||||
// shard cleanup fence against a newer run instead of wiping it.
|
||||
for _, result := range results {
|
||||
if result == nil || result.TypedParams == nil {
|
||||
continue
|
||||
}
|
||||
if ecp := result.TypedParams.GetErasureCodingParams(); ecp != nil {
|
||||
ecp.EncodeTsNs = request.DetectionSequence
|
||||
}
|
||||
}
|
||||
if traceErr := emitErasureCodingDetectionDecisionTrace(sender, metrics, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
|
||||
glog.Warningf("Plugin worker failed to emit erasure_coding detection trace: %v", traceErr)
|
||||
}
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
|
||||
for _, result := range results {
|
||||
proposal, proposalErr := buildErasureCodingProposal(result, h.workingDir)
|
||||
if proposalErr != nil {
|
||||
glog.Warningf("Plugin worker skip invalid erasure_coding proposal: %v", proposalErr)
|
||||
continue
|
||||
}
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "erasure_coding",
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "erasure_coding",
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func emitErasureCodingDetectionDecisionTrace(
|
||||
sender pluginworker.DetectionSender,
|
||||
metrics []*workertypes.VolumeHealthMetrics,
|
||||
taskConfig *Config,
|
||||
results []*workertypes.TaskDetectionResult,
|
||||
maxResults int,
|
||||
hasMore bool,
|
||||
) error {
|
||||
if sender == nil || taskConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
quietThreshold := time.Duration(taskConfig.QuietForSeconds) * time.Second
|
||||
minSizeBytes := uint64(taskConfig.MinSizeMB) * 1024 * 1024
|
||||
allowedCollections, err := wildcard.CompileCollectionMatcher(taskConfig.CollectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
volumeGroups := make(map[uint32][]*workertypes.VolumeHealthMetrics)
|
||||
for _, metric := range metrics {
|
||||
if metric == nil {
|
||||
continue
|
||||
}
|
||||
volumeGroups[metric.VolumeID] = append(volumeGroups[metric.VolumeID], metric)
|
||||
}
|
||||
|
||||
skippedAlreadyEC := 0
|
||||
skippedTooSmall := 0
|
||||
skippedCollectionFilter := 0
|
||||
skippedQuietTime := 0
|
||||
skippedFullness := 0
|
||||
|
||||
for _, groupMetrics := range volumeGroups {
|
||||
if len(groupMetrics) == 0 {
|
||||
continue
|
||||
}
|
||||
metric := groupMetrics[0]
|
||||
for _, candidate := range groupMetrics {
|
||||
if candidate != nil && candidate.Server < metric.Server {
|
||||
metric = candidate
|
||||
}
|
||||
}
|
||||
if metric == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if metric.IsECVolume {
|
||||
skippedAlreadyEC++
|
||||
continue
|
||||
}
|
||||
if metric.Size < minSizeBytes {
|
||||
skippedTooSmall++
|
||||
continue
|
||||
}
|
||||
if !allowedCollections.Matches(metric.Collection) {
|
||||
skippedCollectionFilter++
|
||||
continue
|
||||
}
|
||||
if metric.Age < quietThreshold {
|
||||
skippedQuietTime++
|
||||
continue
|
||||
}
|
||||
if metric.FullnessRatio < taskConfig.FullnessRatio {
|
||||
skippedFullness++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
totalVolumes := len(metrics)
|
||||
summarySuffix := ""
|
||||
if hasMore {
|
||||
summarySuffix = fmt.Sprintf(" (max_results=%d reached; remaining volumes not evaluated)", maxResults)
|
||||
}
|
||||
summaryMessage := ""
|
||||
if len(results) == 0 {
|
||||
summaryMessage = fmt.Sprintf(
|
||||
"EC detection: No tasks created for %d volumes%s (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
|
||||
totalVolumes,
|
||||
summarySuffix,
|
||||
skippedAlreadyEC,
|
||||
skippedTooSmall,
|
||||
skippedCollectionFilter,
|
||||
skippedQuietTime,
|
||||
skippedFullness,
|
||||
)
|
||||
} else {
|
||||
summaryMessage = fmt.Sprintf(
|
||||
"EC detection: Created %d task(s)%s from %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
|
||||
len(results),
|
||||
summarySuffix,
|
||||
totalVolumes,
|
||||
skippedAlreadyEC,
|
||||
skippedTooSmall,
|
||||
skippedCollectionFilter,
|
||||
skippedQuietTime,
|
||||
skippedFullness,
|
||||
)
|
||||
}
|
||||
|
||||
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_volumes": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
|
||||
},
|
||||
"selected_tasks": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
|
||||
},
|
||||
"max_results": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(maxResults)},
|
||||
},
|
||||
"has_more": {
|
||||
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
|
||||
},
|
||||
"skipped_already_ec": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedAlreadyEC)},
|
||||
},
|
||||
"skipped_too_small": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedTooSmall)},
|
||||
},
|
||||
"skipped_filtered": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedCollectionFilter)},
|
||||
},
|
||||
"skipped_not_quiet": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedQuietTime)},
|
||||
},
|
||||
"skipped_not_full": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedFullness)},
|
||||
},
|
||||
"quiet_for_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.QuietForSeconds)},
|
||||
},
|
||||
"min_size_mb": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinSizeMB)},
|
||||
},
|
||||
"fullness_threshold_percent": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.FullnessRatio * 100},
|
||||
},
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
detailsEmitted := 0
|
||||
for _, metric := range metrics {
|
||||
if metric == nil || metric.IsECVolume {
|
||||
continue
|
||||
}
|
||||
sizeMB := float64(metric.Size) / (1024 * 1024)
|
||||
message := fmt.Sprintf(
|
||||
"ERASURE CODING: Volume %d: size=%.1fMB (need ≥%dMB), age=%s (need ≥%s), fullness=%.1f%% (need ≥%.1f%%)",
|
||||
metric.VolumeID,
|
||||
sizeMB,
|
||||
taskConfig.MinSizeMB,
|
||||
metric.Age.Truncate(time.Minute),
|
||||
quietThreshold.Truncate(time.Minute),
|
||||
metric.FullnessRatio*100,
|
||||
taskConfig.FullnessRatio*100,
|
||||
)
|
||||
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.VolumeID)},
|
||||
},
|
||||
"size_mb": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: sizeMB},
|
||||
},
|
||||
"required_min_size_mb": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinSizeMB)},
|
||||
},
|
||||
"age_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.Age.Seconds())},
|
||||
},
|
||||
"required_quiet_for_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.QuietForSeconds)},
|
||||
},
|
||||
"fullness_percent": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: metric.FullnessRatio * 100},
|
||||
},
|
||||
"required_fullness_percent": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.FullnessRatio * 100},
|
||||
},
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
detailsEmitted++
|
||||
if detailsEmitted >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ErasureCodingHandler) Execute(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.ExecuteJobRequest,
|
||||
sender pluginworker.ExecutionSender,
|
||||
) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != "erasure_coding" {
|
||||
return fmt.Errorf("job type %q is not handled by erasure_coding worker", request.Job.JobType)
|
||||
}
|
||||
|
||||
params, err := decodeErasureCodingTaskParams(request.Job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyErasureCodingExecutionDefaults(params, request.GetClusterContext(), h.workingDir)
|
||||
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return fmt.Errorf("erasure coding source node is required")
|
||||
}
|
||||
if len(params.Targets) == 0 {
|
||||
return fmt.Errorf("erasure coding targets are required")
|
||||
}
|
||||
|
||||
task := NewErasureCodingTask(
|
||||
request.Job.JobId,
|
||||
params.Sources[0].Node,
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
h.grpcDialOption,
|
||||
)
|
||||
execCtx, execCancel := context.WithCancel(ctx)
|
||||
defer execCancel()
|
||||
task.SetProgressCallback(func(progress float64, stage string) {
|
||||
message := fmt.Sprintf("erasure coding progress %.0f%%", progress)
|
||||
if strings.TrimSpace(stage) != "" {
|
||||
message = stage
|
||||
}
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity(stage, message),
|
||||
},
|
||||
}); err != nil {
|
||||
execCancel()
|
||||
}
|
||||
})
|
||||
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: "erasure coding job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("assigned", "erasure coding job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := task.Execute(execCtx, params); err != nil {
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_FAILED,
|
||||
ProgressPercent: 100,
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
sourceNode := params.Sources[0].Node
|
||||
resultSummary := fmt.Sprintf("erasure coding completed for volume %d across %d targets", params.VolumeId, len(params.Targets))
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
Success: true,
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_count": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(params.Targets))},
|
||||
},
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ErasureCodingHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
metrics, activeTopology, _, err := pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveErasureCodingWorkerConfig(values map[string]*plugin_pb.ConfigValue) *erasureCodingWorkerConfig {
|
||||
taskConfig := NewDefaultConfig()
|
||||
|
||||
quietForSeconds := pluginworker.ReadIntConfig(values, "quiet_for_seconds", taskConfig.QuietForSeconds)
|
||||
if quietForSeconds < 0 {
|
||||
quietForSeconds = 0
|
||||
}
|
||||
taskConfig.QuietForSeconds = quietForSeconds
|
||||
|
||||
fullnessRatio := pluginworker.ReadDoubleConfig(values, "fullness_ratio", taskConfig.FullnessRatio)
|
||||
if fullnessRatio < 0 {
|
||||
fullnessRatio = 0
|
||||
}
|
||||
if fullnessRatio > 1 {
|
||||
fullnessRatio = 1
|
||||
}
|
||||
taskConfig.FullnessRatio = fullnessRatio
|
||||
|
||||
minSizeMB := pluginworker.ReadIntConfig(values, "min_size_mb", taskConfig.MinSizeMB)
|
||||
if minSizeMB < 1 {
|
||||
minSizeMB = 1
|
||||
}
|
||||
taskConfig.MinSizeMB = minSizeMB
|
||||
|
||||
taskConfig.PreferredTags = util.NormalizeTagList(pluginworker.ReadStringListConfig(values, "preferred_tags"))
|
||||
|
||||
taskConfig.ReplicaPlacement = strings.TrimSpace(pluginworker.ReadStringConfig(values, "replica_placement", taskConfig.ReplicaPlacement))
|
||||
|
||||
return &erasureCodingWorkerConfig{
|
||||
TaskConfig: taskConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func buildErasureCodingProposal(
|
||||
result *workertypes.TaskDetectionResult,
|
||||
baseWorkingDir string,
|
||||
) (*plugin_pb.JobProposal, error) {
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("task detection result is nil")
|
||||
}
|
||||
if result.TypedParams == nil {
|
||||
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
|
||||
}
|
||||
params := proto.Clone(result.TypedParams).(*worker_pb.TaskParams)
|
||||
applyErasureCodingExecutionDefaults(params, nil, baseWorkingDir)
|
||||
|
||||
paramsPayload, err := proto.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal task params: %w", err)
|
||||
}
|
||||
|
||||
proposalID := strings.TrimSpace(result.TaskID)
|
||||
if proposalID == "" {
|
||||
proposalID = fmt.Sprintf("erasure-coding-%d-%d", result.VolumeID, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
dedupeKey := fmt.Sprintf("erasure_coding:%d", result.VolumeID)
|
||||
if result.Collection != "" {
|
||||
dedupeKey += ":" + result.Collection
|
||||
}
|
||||
|
||||
sourceNode := ""
|
||||
if len(params.Sources) > 0 {
|
||||
sourceNode = strings.TrimSpace(params.Sources[0].Node)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Erasure code volume %d", result.VolumeID)
|
||||
if sourceNode != "" {
|
||||
summary = fmt.Sprintf("Erasure code volume %d from %s", result.VolumeID, sourceNode)
|
||||
}
|
||||
|
||||
// EC encoding reads the full volume, computes shards, and writes 14
|
||||
// shards out to target nodes. Budget 10 min/GB (roughly 2x a plain copy)
|
||||
// so the scheduler grants a deadline scaled to volume size.
|
||||
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
|
||||
estimatedRuntimeSeconds := volumeSizeGB * 10 * 60
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: proposalID,
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: "erasure_coding",
|
||||
Priority: pluginworker.MapTaskPriority(result.Priority),
|
||||
Summary: summary,
|
||||
Detail: strings.TrimSpace(result.Reason),
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {
|
||||
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
|
||||
},
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
|
||||
},
|
||||
"target_count": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(params.Targets))},
|
||||
},
|
||||
"estimated_runtime_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "erasure_coding",
|
||||
"volume_id": fmt.Sprintf("%d", result.VolumeID),
|
||||
"collection": result.Collection,
|
||||
"source_node": sourceNode,
|
||||
"target_count": fmt.Sprintf("%d", len(params.Targets)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeErasureCodingTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
|
||||
if job == nil {
|
||||
return nil, fmt.Errorf("job spec is nil")
|
||||
}
|
||||
|
||||
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal task_params_pb: %w", err)
|
||||
}
|
||||
if params.TaskId == "" {
|
||||
params.TaskId = job.JobId
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
volumeID := pluginworker.ReadUint32Config(job.Parameters, "volume_id", 0)
|
||||
sourceNode := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", ""))
|
||||
if sourceNode == "" {
|
||||
sourceNode = strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "server", ""))
|
||||
}
|
||||
targetServers := pluginworker.ReadStringListConfig(job.Parameters, "target_servers")
|
||||
if len(targetServers) == 0 {
|
||||
targetServers = pluginworker.ReadStringListConfig(job.Parameters, "targets")
|
||||
}
|
||||
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
|
||||
|
||||
dataShards := pluginworker.ReadInt32Config(job.Parameters, "data_shards", int32(ecstorage.DataShardsCount))
|
||||
if dataShards <= 0 {
|
||||
dataShards = int32(ecstorage.DataShardsCount)
|
||||
}
|
||||
parityShards := pluginworker.ReadInt32Config(job.Parameters, "parity_shards", int32(ecstorage.ParityShardsCount))
|
||||
if parityShards <= 0 {
|
||||
parityShards = int32(ecstorage.ParityShardsCount)
|
||||
}
|
||||
sourceDiskType := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_disk_type", ""))
|
||||
totalShards := int(dataShards + parityShards)
|
||||
|
||||
if volumeID == 0 {
|
||||
return nil, fmt.Errorf("missing volume_id in job parameters")
|
||||
}
|
||||
if sourceNode == "" {
|
||||
return nil, fmt.Errorf("missing source_server in job parameters")
|
||||
}
|
||||
if len(targetServers) == 0 {
|
||||
return nil, fmt.Errorf("missing target_servers in job parameters")
|
||||
}
|
||||
if len(targetServers) < totalShards {
|
||||
return nil, fmt.Errorf("insufficient target_servers: got %d, need at least %d", len(targetServers), totalShards)
|
||||
}
|
||||
|
||||
shardAssignments := assignECShardIDs(totalShards, len(targetServers))
|
||||
targets := make([]*worker_pb.TaskTarget, 0, len(targetServers))
|
||||
for i := 0; i < len(targetServers); i++ {
|
||||
targetNode := strings.TrimSpace(targetServers[i])
|
||||
if targetNode == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, &worker_pb.TaskTarget{
|
||||
Node: targetNode,
|
||||
VolumeId: volumeID,
|
||||
ShardIds: shardAssignments[i],
|
||||
})
|
||||
}
|
||||
if len(targets) < totalShards {
|
||||
return nil, fmt.Errorf("insufficient non-empty target_servers after normalization: got %d, need at least %d", len(targets), totalShards)
|
||||
}
|
||||
|
||||
return &worker_pb.TaskParams{
|
||||
TaskId: job.JobId,
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: sourceNode,
|
||||
VolumeId: volumeID,
|
||||
},
|
||||
},
|
||||
Targets: targets,
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: dataShards,
|
||||
ParityShards: parityShards,
|
||||
SourceDiskType: sourceDiskType,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyErasureCodingExecutionDefaults(
|
||||
params *worker_pb.TaskParams,
|
||||
clusterContext *plugin_pb.ClusterContext,
|
||||
baseWorkingDir string,
|
||||
) {
|
||||
if params == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ecParams := params.GetErasureCodingParams()
|
||||
if ecParams == nil {
|
||||
ecParams = &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: ecstorage.DataShardsCount,
|
||||
ParityShards: ecstorage.ParityShardsCount,
|
||||
}
|
||||
params.TaskParams = &worker_pb.TaskParams_ErasureCodingParams{ErasureCodingParams: ecParams}
|
||||
}
|
||||
|
||||
if ecParams.DataShards <= 0 {
|
||||
ecParams.DataShards = ecstorage.DataShardsCount
|
||||
}
|
||||
if ecParams.ParityShards <= 0 {
|
||||
ecParams.ParityShards = ecstorage.ParityShardsCount
|
||||
}
|
||||
ecParams.WorkingDir = defaultErasureCodingWorkingDir(baseWorkingDir)
|
||||
ecParams.CleanupSource = true
|
||||
if strings.TrimSpace(ecParams.MasterClient) == "" && clusterContext != nil && len(clusterContext.MasterGrpcAddresses) > 0 {
|
||||
ecParams.MasterClient = clusterContext.MasterGrpcAddresses[0]
|
||||
}
|
||||
|
||||
totalShards := int(ecParams.DataShards + ecParams.ParityShards)
|
||||
if totalShards <= 0 {
|
||||
totalShards = ecstorage.TotalShardsCount
|
||||
}
|
||||
needsShardAssignment := false
|
||||
for _, target := range params.Targets {
|
||||
if target == nil || len(target.ShardIds) == 0 {
|
||||
needsShardAssignment = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsShardAssignment && len(params.Targets) > 0 {
|
||||
assignments := assignECShardIDs(totalShards, len(params.Targets))
|
||||
for i := 0; i < len(params.Targets); i++ {
|
||||
if params.Targets[i] == nil {
|
||||
continue
|
||||
}
|
||||
if len(params.Targets[i].ShardIds) == 0 {
|
||||
params.Targets[i].ShardIds = assignments[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignECShardIDs(totalShards int, targetCount int) [][]uint32 {
|
||||
if targetCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if totalShards <= 0 {
|
||||
totalShards = ecstorage.TotalShardsCount
|
||||
}
|
||||
|
||||
assignments := make([][]uint32, targetCount)
|
||||
for i := 0; i < totalShards; i++ {
|
||||
targetIndex := i % targetCount
|
||||
assignments[targetIndex] = append(assignments[targetIndex], uint32(i))
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func defaultErasureCodingWorkingDir(baseWorkingDir string) string {
|
||||
dir := strings.TrimSpace(baseWorkingDir)
|
||||
if dir == "" {
|
||||
return filepath.Join(".", "erasure_coding")
|
||||
}
|
||||
return filepath.Join(dir, "erasure_coding")
|
||||
}
|
||||
343
weed/worker/tasks/erasure_coding/plugin_handler_test.go
Normal file
343
weed/worker/tasks/erasure_coding/plugin_handler_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDecodeErasureCodingTaskParamsFromPayload(t *testing.T) {
|
||||
expected := &worker_pb.TaskParams{
|
||||
TaskId: "task-ec-1",
|
||||
VolumeId: 88,
|
||||
Collection: "images",
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: "10.0.0.1:8080",
|
||||
VolumeId: 88,
|
||||
},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{
|
||||
Node: "10.0.0.2:8080",
|
||||
VolumeId: 88,
|
||||
ShardIds: []uint32{0, 10},
|
||||
},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: ecstorage.DataShardsCount,
|
||||
ParityShards: ecstorage.ParityShardsCount,
|
||||
WorkingDir: "/tmp/ec-work",
|
||||
CleanupSource: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := proto.Marshal(expected)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-from-admin",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
actual, err := decodeErasureCodingTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeErasureCodingTaskParams() err = %v", err)
|
||||
}
|
||||
if !proto.Equal(expected, actual) {
|
||||
t.Fatalf("decoded params mismatch\nexpected: %+v\nactual: %+v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeErasureCodingTaskParamsFallback(t *testing.T) {
|
||||
targetServers := make([]string, 0, ecstorage.TotalShardsCount)
|
||||
for i := 0; i < ecstorage.TotalShardsCount; i++ {
|
||||
targetServers = append(targetServers, "10.0.0."+string(rune('a'+i))+":8080")
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-ec-2",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.1:8080"},
|
||||
},
|
||||
"target_servers": {
|
||||
Kind: &plugin_pb.ConfigValue_StringList{
|
||||
StringList: &plugin_pb.StringList{Values: targetServers},
|
||||
},
|
||||
},
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "videos"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
params, err := decodeErasureCodingTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeErasureCodingTaskParams() err = %v", err)
|
||||
}
|
||||
if params.TaskId != "job-ec-2" || params.VolumeId != 7 || params.Collection != "videos" {
|
||||
t.Fatalf("unexpected basic params: %+v", params)
|
||||
}
|
||||
if len(params.Sources) != 1 || params.Sources[0].Node != "127.0.0.1:8080" {
|
||||
t.Fatalf("unexpected sources: %+v", params.Sources)
|
||||
}
|
||||
if len(params.Targets) != ecstorage.TotalShardsCount {
|
||||
t.Fatalf("unexpected target count: %d", len(params.Targets))
|
||||
}
|
||||
if params.GetErasureCodingParams() == nil {
|
||||
t.Fatalf("expected fallback erasure coding params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveErasureCodingWorkerConfig(t *testing.T) {
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"quiet_for_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 720},
|
||||
},
|
||||
"fullness_ratio": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.92},
|
||||
},
|
||||
"min_size_mb": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 128},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := deriveErasureCodingWorkerConfig(values)
|
||||
if cfg.TaskConfig.QuietForSeconds != 720 {
|
||||
t.Fatalf("expected quiet_for_seconds 720, got %d", cfg.TaskConfig.QuietForSeconds)
|
||||
}
|
||||
if cfg.TaskConfig.FullnessRatio != 0.92 {
|
||||
t.Fatalf("expected fullness_ratio 0.92, got %v", cfg.TaskConfig.FullnessRatio)
|
||||
}
|
||||
if cfg.TaskConfig.MinSizeMB != 128 {
|
||||
t.Fatalf("expected min_size_mb 128, got %d", cfg.TaskConfig.MinSizeMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildErasureCodingProposal(t *testing.T) {
|
||||
params := &worker_pb.TaskParams{
|
||||
TaskId: "ec-task-1",
|
||||
VolumeId: 99,
|
||||
Collection: "c1",
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: "source-a:8080",
|
||||
VolumeId: 99,
|
||||
},
|
||||
},
|
||||
Targets: []*worker_pb.TaskTarget{
|
||||
{
|
||||
Node: "target-a:8080",
|
||||
VolumeId: 99,
|
||||
ShardIds: []uint32{0, 10},
|
||||
},
|
||||
{
|
||||
Node: "target-b:8080",
|
||||
VolumeId: 99,
|
||||
ShardIds: []uint32{1, 11},
|
||||
},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: ecstorage.DataShardsCount,
|
||||
ParityShards: ecstorage.ParityShardsCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
result := &workertypes.TaskDetectionResult{
|
||||
TaskID: "ec-task-1",
|
||||
TaskType: workertypes.TaskTypeErasureCoding,
|
||||
VolumeID: 99,
|
||||
Server: "source-a",
|
||||
Collection: "c1",
|
||||
Priority: workertypes.TaskPriorityLow,
|
||||
Reason: "quiet and full",
|
||||
TypedParams: params,
|
||||
}
|
||||
|
||||
proposal, err := buildErasureCodingProposal(result, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildErasureCodingProposal() err = %v", err)
|
||||
}
|
||||
if proposal.JobType != "erasure_coding" {
|
||||
t.Fatalf("unexpected job type %q", proposal.JobType)
|
||||
}
|
||||
if proposal.Parameters["task_params_pb"] == nil {
|
||||
t.Fatalf("expected serialized task params")
|
||||
}
|
||||
if proposal.Labels["source_node"] != "source-a:8080" {
|
||||
t.Fatalf("unexpected source label %q", proposal.Labels["source_node"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestErasureCodingHandlerRejectsUnsupportedJobType(t *testing.T) {
|
||||
handler := NewErasureCodingHandler(nil, "")
|
||||
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: "vacuum",
|
||||
}, noopDetectionSender{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected detect job type mismatch error")
|
||||
}
|
||||
|
||||
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobId: "job-1", JobType: "vacuum"},
|
||||
}, noopExecutionSender{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected execute job type mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitErasureCodingDetectionDecisionTraceNoTasks(t *testing.T) {
|
||||
sender := &recordingDetectionSender{}
|
||||
config := NewDefaultConfig()
|
||||
config.QuietForSeconds = 5 * 60
|
||||
config.MinSizeMB = 30
|
||||
config.FullnessRatio = 0.91
|
||||
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{
|
||||
VolumeID: 20,
|
||||
Size: 0,
|
||||
Age: 218*time.Hour + 41*time.Minute,
|
||||
FullnessRatio: 0,
|
||||
},
|
||||
{
|
||||
VolumeID: 27,
|
||||
Size: uint64(16 * 1024 * 1024 / 10),
|
||||
Age: 91*time.Hour + time.Minute,
|
||||
FullnessRatio: 0.002,
|
||||
},
|
||||
{
|
||||
VolumeID: 12,
|
||||
Size: 0,
|
||||
Age: 219*time.Hour + 49*time.Minute,
|
||||
FullnessRatio: 0,
|
||||
},
|
||||
}
|
||||
|
||||
if err := emitErasureCodingDetectionDecisionTrace(sender, metrics, config, nil, 0, false); err != nil {
|
||||
t.Fatalf("emitErasureCodingDetectionDecisionTrace error: %v", err)
|
||||
}
|
||||
if len(sender.events) < 4 {
|
||||
t.Fatalf("expected at least 4 detection events, got %d", len(sender.events))
|
||||
}
|
||||
|
||||
if sender.events[0].Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR {
|
||||
t.Fatalf("expected detector source, got %v", sender.events[0].Source)
|
||||
}
|
||||
if !strings.Contains(sender.events[0].Message, "EC detection: No tasks created for 3 volumes") {
|
||||
t.Fatalf("unexpected summary message: %q", sender.events[0].Message)
|
||||
}
|
||||
if !strings.Contains(sender.events[1].Message, "ERASURE CODING: Volume 20: size=0.0MB") {
|
||||
t.Fatalf("unexpected first detail message: %q", sender.events[1].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErasureCodingDescriptorOmitsLocalExecutionFields(t *testing.T) {
|
||||
descriptor := NewErasureCodingHandler(nil, "").Descriptor()
|
||||
if descriptor == nil || descriptor.WorkerConfigForm == nil {
|
||||
t.Fatalf("expected worker config form in descriptor")
|
||||
}
|
||||
if workerConfigFormHasField(descriptor.WorkerConfigForm, "working_dir") {
|
||||
t.Fatalf("unexpected working_dir in erasure coding worker config form")
|
||||
}
|
||||
if workerConfigFormHasField(descriptor.WorkerConfigForm, "cleanup_source") {
|
||||
t.Fatalf("unexpected cleanup_source in erasure coding worker config form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyErasureCodingExecutionDefaultsForcesLocalFields(t *testing.T) {
|
||||
baseWorkingDir := "/var/lib/seaweedfs-worker"
|
||||
params := &worker_pb.TaskParams{
|
||||
TaskId: "ec-test",
|
||||
VolumeId: 100,
|
||||
TaskParams: &worker_pb.TaskParams_ErasureCodingParams{
|
||||
ErasureCodingParams: &worker_pb.ErasureCodingTaskParams{
|
||||
DataShards: ecstorage.DataShardsCount,
|
||||
ParityShards: ecstorage.ParityShardsCount,
|
||||
WorkingDir: "/tmp/custom-from-job",
|
||||
CleanupSource: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
applyErasureCodingExecutionDefaults(params, nil, baseWorkingDir)
|
||||
|
||||
ecParams := params.GetErasureCodingParams()
|
||||
if ecParams == nil {
|
||||
t.Fatalf("expected erasure coding params")
|
||||
}
|
||||
if ecParams.WorkingDir != defaultErasureCodingWorkingDir(baseWorkingDir) {
|
||||
t.Fatalf("expected local working_dir %q, got %q", defaultErasureCodingWorkingDir(baseWorkingDir), ecParams.WorkingDir)
|
||||
}
|
||||
if !ecParams.CleanupSource {
|
||||
t.Fatalf("expected cleanup_source true")
|
||||
}
|
||||
}
|
||||
|
||||
type noopDetectionSender struct{}
|
||||
|
||||
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
|
||||
func (noopDetectionSender) SendComplete(*plugin_pb.DetectionComplete) error { return nil }
|
||||
func (noopDetectionSender) SendActivity(*plugin_pb.ActivityEvent) error { return nil }
|
||||
|
||||
type noopExecutionSender struct{}
|
||||
|
||||
func (noopExecutionSender) SendProgress(*plugin_pb.JobProgressUpdate) error { return nil }
|
||||
func (noopExecutionSender) SendCompleted(*plugin_pb.JobCompleted) error { return nil }
|
||||
|
||||
type recordingDetectionSender struct {
|
||||
proposals *plugin_pb.DetectionProposals
|
||||
complete *plugin_pb.DetectionComplete
|
||||
events []*plugin_pb.ActivityEvent
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendProposals(proposals *plugin_pb.DetectionProposals) error {
|
||||
r.proposals = proposals
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendComplete(complete *plugin_pb.DetectionComplete) error {
|
||||
r.complete = complete
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendActivity(event *plugin_pb.ActivityEvent) error {
|
||||
if event != nil {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
|
||||
if form == nil {
|
||||
return false
|
||||
}
|
||||
for _, section := range form.Sections {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
for _, field := range section.Fields {
|
||||
if field != nil && field.Name == fieldName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
96
weed/worker/tasks/erasure_coding/register.go
Normal file
96
weed/worker/tasks/erasure_coding/register.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Global variable to hold the task definition for configuration updates
|
||||
var globalTaskDef *base.TaskDefinition
|
||||
|
||||
// Auto-register this task when the package is imported
|
||||
func init() {
|
||||
RegisterErasureCodingTask()
|
||||
|
||||
// Register config updater
|
||||
tasks.AutoRegisterConfigUpdater(types.TaskTypeErasureCoding, UpdateConfigFromPersistence)
|
||||
}
|
||||
|
||||
// RegisterErasureCodingTask registers the erasure coding task with the new architecture
|
||||
func RegisterErasureCodingTask() {
|
||||
// Create configuration instance
|
||||
config := NewDefaultConfig()
|
||||
|
||||
// Create shared gRPC dial option using TLS configuration
|
||||
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
|
||||
|
||||
// Create complete task definition
|
||||
taskDef := &base.TaskDefinition{
|
||||
Type: types.TaskTypeErasureCoding,
|
||||
Name: "erasure_coding",
|
||||
DisplayName: "Erasure Coding",
|
||||
Description: "Applies erasure coding to volumes for data protection",
|
||||
Icon: "fas fa-shield-alt text-success",
|
||||
Capabilities: []string{"erasure_coding", "data_protection"},
|
||||
|
||||
Config: config,
|
||||
ConfigSpec: GetConfigSpec(),
|
||||
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
|
||||
if params == nil {
|
||||
return nil, fmt.Errorf("task parameters are required")
|
||||
}
|
||||
if len(params.Sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required for erasure coding task")
|
||||
}
|
||||
return NewErasureCodingTask(
|
||||
fmt.Sprintf("erasure_coding-%d", params.VolumeId),
|
||||
params.Sources[0].Node, // Use first source node
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
dialOpt,
|
||||
), nil
|
||||
},
|
||||
DetectionFunc: func(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
|
||||
results, _, err := Detection(context.Background(), metrics, clusterInfo, config, 0)
|
||||
return results, err
|
||||
},
|
||||
ScanInterval: 1 * time.Hour,
|
||||
SchedulingFunc: Scheduling,
|
||||
MaxConcurrent: 1,
|
||||
RepeatInterval: 24 * time.Hour,
|
||||
}
|
||||
|
||||
// Store task definition globally for configuration updates
|
||||
globalTaskDef = taskDef
|
||||
|
||||
// Register everything with a single function call!
|
||||
base.RegisterTask(taskDef)
|
||||
}
|
||||
|
||||
// UpdateConfigFromPersistence updates the erasure coding configuration from persistence
|
||||
func UpdateConfigFromPersistence(configPersistence interface{}) error {
|
||||
if globalTaskDef == nil {
|
||||
return fmt.Errorf("erasure coding task not registered")
|
||||
}
|
||||
|
||||
// Load configuration from persistence
|
||||
newConfig := LoadConfigFromPersistence(configPersistence)
|
||||
if newConfig == nil {
|
||||
return fmt.Errorf("failed to load configuration from persistence")
|
||||
}
|
||||
|
||||
// Update the task definition's config
|
||||
globalTaskDef.Config = newConfig
|
||||
|
||||
glog.V(1).Infof("Updated erasure coding task configuration from persistence")
|
||||
return nil
|
||||
}
|
||||
40
weed/worker/tasks/erasure_coding/scheduling.go
Normal file
40
weed/worker/tasks/erasure_coding/scheduling.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package erasure_coding
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Scheduling implements the scheduling logic for erasure coding tasks
|
||||
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
|
||||
ecConfig := config.(*Config)
|
||||
|
||||
// Check if we have available workers
|
||||
if len(availableWorkers) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Count running EC tasks
|
||||
runningCount := 0
|
||||
for _, runningTask := range runningTasks {
|
||||
if runningTask.Type == types.TaskTypeErasureCoding {
|
||||
runningCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrency limit
|
||||
if runningCount >= ecConfig.MaxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any worker can handle EC tasks
|
||||
for _, worker := range availableWorkers {
|
||||
for _, capability := range worker.Capabilities {
|
||||
if capability == types.TaskTypeErasureCoding {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
1288
weed/worker/tasks/iceberg/compact.go
Normal file
1288
weed/worker/tasks/iceberg/compact.go
Normal file
File diff suppressed because it is too large
Load Diff
371
weed/worker/tasks/iceberg/config.go
Normal file
371
weed/worker/tasks/iceberg/config.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
const (
|
||||
jobType = "iceberg_maintenance"
|
||||
|
||||
defaultSnapshotRetentionHours = 168 // 7 days
|
||||
defaultMaxSnapshotsToKeep = 5
|
||||
defaultOrphanOlderThanHours = 72
|
||||
defaultMaxCommitRetries = 5
|
||||
defaultTargetFileSizeMB = 256
|
||||
defaultMinInputFiles = 5
|
||||
defaultDeleteTargetFileSizeMB = 64
|
||||
defaultDeleteMinInputFiles = 2
|
||||
defaultDeleteMaxGroupSizeMB = 256
|
||||
defaultDeleteMaxOutputFiles = 8
|
||||
defaultRewriteStrategy = "binpack"
|
||||
rewriteStrategyAuto = "auto"
|
||||
defaultSortBufferRows = 262144
|
||||
minSortBufferRows = 1024
|
||||
defaultMinManifestsToRewrite = 5
|
||||
minManifestsToRewrite = 2
|
||||
defaultOperations = "all"
|
||||
|
||||
// Metric keys returned by maintenance operations.
|
||||
MetricFilesMerged = "files_merged"
|
||||
MetricFilesWritten = "files_written"
|
||||
MetricBins = "bins"
|
||||
MetricSnapshotsExpired = "snapshots_expired"
|
||||
MetricFilesDeleted = "files_deleted"
|
||||
MetricOrphansRemoved = "orphans_removed"
|
||||
MetricManifestsRewritten = "manifests_rewritten"
|
||||
MetricDeleteFilesRewritten = "delete_files_rewritten"
|
||||
MetricDeleteFilesWritten = "delete_files_written"
|
||||
MetricDeleteBytesRewritten = "delete_bytes_rewritten"
|
||||
MetricDeleteGroupsPlanned = "delete_groups_planned"
|
||||
MetricDeleteGroupsSkipped = "delete_groups_skipped"
|
||||
MetricEntriesTotal = "entries_total"
|
||||
MetricDurationMs = "duration_ms"
|
||||
)
|
||||
|
||||
const bytesPerMB int64 = 1024 * 1024
|
||||
|
||||
const msPerHour int64 = 3600 * 1000
|
||||
|
||||
// maxOrphanOlderThanHours is the largest cutoff that still survives conversion
|
||||
// to a time.Duration. Beyond it the multiplication wraps negative, putting the
|
||||
// cutoff in the future and making every file look like an orphan.
|
||||
const maxOrphanOlderThanHours = int64(math.MaxInt64 / int64(time.Hour))
|
||||
|
||||
// mbToBytes converts megabytes to bytes, saturating instead of overflowing.
|
||||
func mbToBytes(mb int64) int64 {
|
||||
if mb <= 0 {
|
||||
return 0
|
||||
}
|
||||
if mb > math.MaxInt64/bytesPerMB {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return mb * bytesPerMB
|
||||
}
|
||||
|
||||
// addDays sums two day counts, saturating instead of overflowing. Negative
|
||||
// inputs are treated as unset.
|
||||
func addDays(a, b int64) int64 {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
if a > math.MaxInt64-b {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
// daysToHours converts days to hours, saturating instead of overflowing.
|
||||
// applyThresholdDefaults clamps the result down to a usable cutoff.
|
||||
func daysToHours(days int64) int64 {
|
||||
if days <= 0 {
|
||||
return 0
|
||||
}
|
||||
if days > math.MaxInt64/24 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return days * 24
|
||||
}
|
||||
|
||||
// hoursToMs converts hours to milliseconds, saturating instead of overflowing.
|
||||
func hoursToMs(hours int64) int64 {
|
||||
if hours <= 0 {
|
||||
return 0
|
||||
}
|
||||
if hours > math.MaxInt64/msPerHour {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return hours * msPerHour
|
||||
}
|
||||
|
||||
// Config holds parsed worker config values.
|
||||
type Config struct {
|
||||
// Milliseconds rather than hours so sub-hour retentions survive; the
|
||||
// plugin config key stays in hours.
|
||||
SnapshotRetentionMs int64
|
||||
MaxSnapshotsToKeep int64
|
||||
OrphanOlderThanHours int64
|
||||
MaxCommitRetries int64
|
||||
TargetFileSizeBytes int64
|
||||
MinInputFiles int64
|
||||
DeleteTargetFileSizeBytes int64
|
||||
DeleteMinInputFiles int64
|
||||
DeleteMaxFileGroupSizeBytes int64
|
||||
DeleteMaxOutputFiles int64
|
||||
MinManifestsToRewrite int64
|
||||
Operations string
|
||||
TablePropertiesOverride bool
|
||||
ApplyDeletes bool
|
||||
Where string
|
||||
RewriteStrategy string
|
||||
SortMaxInputBytes int64
|
||||
SortBufferRows int64
|
||||
SortSpillDir string
|
||||
}
|
||||
|
||||
// ParseConfig extracts an iceberg maintenance Config from plugin config values.
|
||||
// Values are clamped to safe minimums to prevent misconfiguration.
|
||||
func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
||||
cfg := Config{
|
||||
SnapshotRetentionMs: hoursToMs(readInt64Config(values, "snapshot_retention_hours", defaultSnapshotRetentionHours)),
|
||||
MaxSnapshotsToKeep: readInt64Config(values, "max_snapshots_to_keep", defaultMaxSnapshotsToKeep),
|
||||
OrphanOlderThanHours: readInt64Config(values, "orphan_older_than_hours", defaultOrphanOlderThanHours),
|
||||
MaxCommitRetries: readInt64Config(values, "max_commit_retries", defaultMaxCommitRetries),
|
||||
TargetFileSizeBytes: readSizeMBConfig(values, "target_file_size_mb", defaultTargetFileSizeMB),
|
||||
MinInputFiles: readInt64Config(values, "min_input_files", defaultMinInputFiles),
|
||||
DeleteTargetFileSizeBytes: readSizeMBConfig(values, "delete_target_file_size_mb", defaultDeleteTargetFileSizeMB),
|
||||
DeleteMinInputFiles: readInt64Config(values, "delete_min_input_files", defaultDeleteMinInputFiles),
|
||||
DeleteMaxFileGroupSizeBytes: readSizeMBConfig(values, "delete_max_file_group_size_mb", defaultDeleteMaxGroupSizeMB),
|
||||
DeleteMaxOutputFiles: readInt64Config(values, "delete_max_output_files", defaultDeleteMaxOutputFiles),
|
||||
MinManifestsToRewrite: readInt64Config(values, "min_manifests_to_rewrite", defaultMinManifestsToRewrite),
|
||||
Operations: readStringConfig(values, "operations", defaultOperations),
|
||||
TablePropertiesOverride: readBoolConfig(values, "table_properties_override", true),
|
||||
ApplyDeletes: readBoolConfig(values, "apply_deletes", true),
|
||||
Where: strings.TrimSpace(readStringConfig(values, "where", "")),
|
||||
RewriteStrategy: strings.TrimSpace(strings.ToLower(readStringConfig(values, "rewrite_strategy", defaultRewriteStrategy))),
|
||||
SortMaxInputBytes: readSizeMBConfig(values, "sort_max_input_mb", 0),
|
||||
SortBufferRows: readInt64Config(values, "sort_buffer_rows", defaultSortBufferRows),
|
||||
SortSpillDir: strings.TrimSpace(readStringConfig(values, "sort_spill_dir", "")),
|
||||
}
|
||||
|
||||
// Clamp the fields that are always defaulted by worker config parsing.
|
||||
if cfg.SnapshotRetentionMs <= 0 {
|
||||
cfg.SnapshotRetentionMs = hoursToMs(defaultSnapshotRetentionHours)
|
||||
}
|
||||
if cfg.MaxSnapshotsToKeep <= 0 {
|
||||
cfg.MaxSnapshotsToKeep = defaultMaxSnapshotsToKeep
|
||||
}
|
||||
if cfg.MaxCommitRetries <= 0 {
|
||||
cfg.MaxCommitRetries = defaultMaxCommitRetries
|
||||
}
|
||||
cfg = applyThresholdDefaults(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func applyThresholdDefaults(cfg Config) Config {
|
||||
if cfg.OrphanOlderThanHours <= 0 {
|
||||
cfg.OrphanOlderThanHours = defaultOrphanOlderThanHours
|
||||
}
|
||||
if cfg.OrphanOlderThanHours > maxOrphanOlderThanHours {
|
||||
cfg.OrphanOlderThanHours = maxOrphanOlderThanHours
|
||||
}
|
||||
if cfg.TargetFileSizeBytes <= 0 {
|
||||
cfg.TargetFileSizeBytes = defaultTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
if cfg.MinInputFiles < 2 {
|
||||
cfg.MinInputFiles = defaultMinInputFiles
|
||||
}
|
||||
if cfg.DeleteTargetFileSizeBytes <= 0 {
|
||||
cfg.DeleteTargetFileSizeBytes = defaultDeleteTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
if cfg.DeleteMinInputFiles < 2 {
|
||||
cfg.DeleteMinInputFiles = defaultDeleteMinInputFiles
|
||||
}
|
||||
if cfg.DeleteMaxFileGroupSizeBytes <= 0 {
|
||||
cfg.DeleteMaxFileGroupSizeBytes = defaultDeleteMaxGroupSizeMB * 1024 * 1024
|
||||
}
|
||||
if cfg.DeleteMaxOutputFiles <= 0 {
|
||||
cfg.DeleteMaxOutputFiles = defaultDeleteMaxOutputFiles
|
||||
}
|
||||
if cfg.RewriteStrategy == "" {
|
||||
cfg.RewriteStrategy = defaultRewriteStrategy
|
||||
}
|
||||
if cfg.RewriteStrategy != "binpack" && cfg.RewriteStrategy != "sort" && cfg.RewriteStrategy != rewriteStrategyAuto {
|
||||
cfg.RewriteStrategy = defaultRewriteStrategy
|
||||
}
|
||||
if cfg.SortMaxInputBytes < 0 {
|
||||
cfg.SortMaxInputBytes = 0
|
||||
}
|
||||
// A run smaller than this buys no memory back worth the extra runs, and
|
||||
// the writer stops at 32K of them, so a tiny value would cap the output.
|
||||
if cfg.SortBufferRows < minSortBufferRows {
|
||||
cfg.SortBufferRows = defaultSortBufferRows
|
||||
}
|
||||
if cfg.MinManifestsToRewrite < minManifestsToRewrite {
|
||||
cfg.MinManifestsToRewrite = minManifestsToRewrite
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func readSizeMBConfig(values map[string]*plugin_pb.ConfigValue, field string, fallbackMB int64) int64 {
|
||||
mb := readInt64Config(values, field, fallbackMB)
|
||||
if mb <= 0 {
|
||||
return 0
|
||||
}
|
||||
maxMB := int64(^uint64(0)>>1) / bytesPerMB
|
||||
if mb > maxMB {
|
||||
glog.V(1).Infof("readSizeMBConfig: clamping %q from %d MB to %d MB", field, mb, maxMB)
|
||||
mb = maxMB
|
||||
}
|
||||
return mb * bytesPerMB
|
||||
}
|
||||
|
||||
// parseOperations returns the ordered list of maintenance operations to execute.
|
||||
// Order follows Iceberg best practices: compact → rewrite_position_delete_files
|
||||
// → expire_snapshots → remove_orphans → rewrite_manifests.
|
||||
// Returns an error if any unknown operation is specified or the result would be empty.
|
||||
func parseOperations(ops string) ([]string, error) {
|
||||
ops = strings.TrimSpace(strings.ToLower(ops))
|
||||
if ops == "" || ops == "all" {
|
||||
return []string{"compact", "rewrite_position_delete_files", "expire_snapshots", "remove_orphans", "rewrite_manifests"}, nil
|
||||
}
|
||||
|
||||
validOps := map[string]struct{}{
|
||||
"compact": {},
|
||||
"rewrite_position_delete_files": {},
|
||||
"expire_snapshots": {},
|
||||
"remove_orphans": {},
|
||||
"rewrite_manifests": {},
|
||||
}
|
||||
|
||||
requested := make(map[string]struct{})
|
||||
for _, op := range strings.Split(ops, ",") {
|
||||
op = strings.TrimSpace(op)
|
||||
if op == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := validOps[op]; !ok {
|
||||
return nil, fmt.Errorf("unknown maintenance operation %q (valid: compact, rewrite_position_delete_files, expire_snapshots, remove_orphans, rewrite_manifests)", op)
|
||||
}
|
||||
requested[op] = struct{}{}
|
||||
}
|
||||
|
||||
// Return in canonical order: compact → rewrite_position_delete_files →
|
||||
// expire_snapshots → remove_orphans → rewrite_manifests
|
||||
canonicalOrder := []string{"compact", "rewrite_position_delete_files", "expire_snapshots", "remove_orphans", "rewrite_manifests"}
|
||||
var result []string
|
||||
for _, op := range canonicalOrder {
|
||||
if _, ok := requested[op]; ok {
|
||||
result = append(result, op)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("no valid maintenance operations specified")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func extractMetadataVersion(metadataFileName string) int {
|
||||
// Parse "v3.metadata.json" or "v3-{nonce}.metadata.json" → 3
|
||||
name := strings.TrimPrefix(metadataFileName, "v")
|
||||
name = strings.TrimSuffix(name, ".metadata.json")
|
||||
// Strip any nonce suffix (e.g. "3-1709766000" → "3")
|
||||
if dashIdx := strings.Index(name, "-"); dashIdx > 0 {
|
||||
name = name[:dashIdx]
|
||||
}
|
||||
version, _ := strconv.Atoi(name)
|
||||
return version
|
||||
}
|
||||
|
||||
// readStringConfig reads a string value from plugin config, with fallback.
|
||||
func readStringConfig(values map[string]*plugin_pb.ConfigValue, field string, fallback string) string {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
value := values[field]
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch kind := value.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
return kind.StringValue
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return strconv.FormatInt(kind.Int64Value, 10)
|
||||
case *plugin_pb.ConfigValue_DoubleValue:
|
||||
return strconv.FormatFloat(kind.DoubleValue, 'f', -1, 64)
|
||||
case *plugin_pb.ConfigValue_BoolValue:
|
||||
return strconv.FormatBool(kind.BoolValue)
|
||||
default:
|
||||
glog.V(1).Infof("readStringConfig: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// readBoolConfig reads a bool value from plugin config, with fallback.
|
||||
func readBoolConfig(values map[string]*plugin_pb.ConfigValue, field string, fallback bool) bool {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
value := values[field]
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch kind := value.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_BoolValue:
|
||||
return kind.BoolValue
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
s := strings.TrimSpace(strings.ToLower(kind.StringValue))
|
||||
if s == "true" || s == "1" || s == "yes" {
|
||||
return true
|
||||
}
|
||||
if s == "false" || s == "0" || s == "no" {
|
||||
return false
|
||||
}
|
||||
glog.V(1).Infof("readBoolConfig: unrecognized string value %q for field %q, using fallback %v", kind.StringValue, field, fallback)
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return kind.Int64Value != 0
|
||||
default:
|
||||
glog.V(1).Infof("readBoolConfig: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// readInt64Config reads an int64 value from plugin config, with fallback.
|
||||
func readInt64Config(values map[string]*plugin_pb.ConfigValue, field string, fallback int64) int64 {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
value := values[field]
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch kind := value.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return kind.Int64Value
|
||||
case *plugin_pb.ConfigValue_DoubleValue:
|
||||
return int64(kind.DoubleValue)
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(kind.StringValue), 10, 64)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
case *plugin_pb.ConfigValue_BoolValue:
|
||||
if kind.BoolValue {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
glog.V(1).Infof("readInt64Config: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
607
weed/worker/tasks/iceberg/delete_rewrite.go
Normal file
607
weed/worker/tasks/iceberg/delete_rewrite.go
Normal file
@@ -0,0 +1,607 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
type deleteRewriteInput struct {
|
||||
Entry iceberg.ManifestEntry
|
||||
ReferencedPath string
|
||||
Positions []int64
|
||||
}
|
||||
|
||||
type deleteRewriteGroup struct {
|
||||
SpecID int32
|
||||
Partition map[int]any
|
||||
PartitionKey string
|
||||
ReferencedPath string
|
||||
Inputs []deleteRewriteInput
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
type positionDeleteRow struct {
|
||||
FilePath string `parquet:"file_path"`
|
||||
Pos int64 `parquet:"pos"`
|
||||
}
|
||||
|
||||
func hasEligibleDeleteRewrite(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
manifests []iceberg.ManifestFile,
|
||||
config Config,
|
||||
meta table.Metadata,
|
||||
predicate *partitionPredicate,
|
||||
) (bool, error) {
|
||||
groups, _, err := collectDeleteRewriteGroups(ctx, filerClient, bucketName, dataPath, manifests, specByID(meta), meta.CurrentSchema())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, group := range groups {
|
||||
if predicate != nil {
|
||||
spec, ok := specByID(meta)[int(group.SpecID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
match, err := predicate.Matches(spec, group.Partition)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if groupEligibleForRewrite(group, config) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func collectDeleteRewriteGroups(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
manifests []iceberg.ManifestFile,
|
||||
specs map[int]iceberg.PartitionSpec,
|
||||
schema *iceberg.Schema,
|
||||
) (map[string]*deleteRewriteGroup, []iceberg.ManifestEntry, error) {
|
||||
groups := make(map[string]*deleteRewriteGroup)
|
||||
var allPositionEntries []iceberg.ManifestEntry
|
||||
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentDeletes {
|
||||
continue
|
||||
}
|
||||
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read delete manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := s3tables.ReadManifest(mf, manifestData, true, specs, schema)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse delete manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.DataFile().ContentType() != iceberg.EntryContentPosDeletes {
|
||||
continue
|
||||
}
|
||||
|
||||
allPositionEntries = append(allPositionEntries, entry)
|
||||
|
||||
fileDeletes, err := readPositionDeleteFile(ctx, filerClient, bucketName, dataPath, entry.DataFile().FilePath())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read position delete file %s: %w", entry.DataFile().FilePath(), err)
|
||||
}
|
||||
if len(fileDeletes) != 1 {
|
||||
// Phase 1 only rewrites files that target a single data file.
|
||||
continue
|
||||
}
|
||||
|
||||
var referencedPath string
|
||||
var positions []int64
|
||||
for fp, pos := range fileDeletes {
|
||||
normalized, err := normalizeIcebergPath(fp, bucketName, dataPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve deleted file %s: %w", fp, err)
|
||||
}
|
||||
// The rewritten delete file has to name the data file the way the
|
||||
// table does, so keep the absolute form readers compare against.
|
||||
referencedPath = absoluteIcebergPath(bucketName, normalized)
|
||||
positions = append(positions, pos...)
|
||||
}
|
||||
sort.Slice(positions, func(i, j int) bool { return positions[i] < positions[j] })
|
||||
|
||||
partKey := partitionKey(entry.DataFile().Partition())
|
||||
groupKey := fmt.Sprintf("spec%d\x00%s\x00%s", entry.DataFile().SpecID(), partKey, referencedPath)
|
||||
group, ok := groups[groupKey]
|
||||
if !ok {
|
||||
group = &deleteRewriteGroup{
|
||||
SpecID: entry.DataFile().SpecID(),
|
||||
Partition: entry.DataFile().Partition(),
|
||||
PartitionKey: partKey,
|
||||
ReferencedPath: referencedPath,
|
||||
}
|
||||
groups[groupKey] = group
|
||||
}
|
||||
group.Inputs = append(group.Inputs, deleteRewriteInput{
|
||||
Entry: entry,
|
||||
ReferencedPath: referencedPath,
|
||||
Positions: positions,
|
||||
})
|
||||
group.TotalSize += entry.DataFile().FileSizeBytes()
|
||||
}
|
||||
}
|
||||
|
||||
return groups, allPositionEntries, nil
|
||||
}
|
||||
|
||||
func groupEligibleForRewrite(group *deleteRewriteGroup, config Config) bool {
|
||||
if group == nil {
|
||||
return false
|
||||
}
|
||||
if len(group.Inputs) < 2 {
|
||||
return false
|
||||
}
|
||||
if group.TotalSize > config.DeleteMaxFileGroupSizeBytes {
|
||||
return false
|
||||
}
|
||||
target := config.DeleteTargetFileSizeBytes
|
||||
if target <= 0 {
|
||||
target = defaultDeleteTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
outputFiles := int64(estimatedDeleteOutputFiles(group.TotalSize, target))
|
||||
if config.DeleteMaxOutputFiles > 0 && outputFiles > config.DeleteMaxOutputFiles {
|
||||
return false
|
||||
}
|
||||
return int64(len(group.Inputs)) >= config.DeleteMinInputFiles
|
||||
}
|
||||
|
||||
func estimatedDeleteOutputFiles(totalSize, targetSize int64) int {
|
||||
if totalSize <= 0 || targetSize <= 0 {
|
||||
return 1
|
||||
}
|
||||
count := int(math.Ceil(float64(totalSize) / float64(targetSize)))
|
||||
if count < 1 {
|
||||
return 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func manifestEntrySeqNum(entry iceberg.ManifestEntry) *int64 {
|
||||
seqNum := entry.SequenceNum()
|
||||
if seqNum < 0 {
|
||||
return nil
|
||||
}
|
||||
return &seqNum
|
||||
}
|
||||
|
||||
func manifestEntryFileSeqNum(entry iceberg.ManifestEntry) *int64 {
|
||||
if fileSeqNum := entry.FileSequenceNum(); fileSeqNum != nil {
|
||||
value := *fileSeqNum
|
||||
return &value
|
||||
}
|
||||
return manifestEntrySeqNum(entry)
|
||||
}
|
||||
|
||||
func writeManifestWithContent(
|
||||
filename string,
|
||||
version int,
|
||||
spec iceberg.PartitionSpec,
|
||||
schema *iceberg.Schema,
|
||||
snapshotID int64,
|
||||
entries []iceberg.ManifestEntry,
|
||||
content iceberg.ManifestContent,
|
||||
) (iceberg.ManifestFile, []byte, error) {
|
||||
var manifestBuf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(filename, &manifestBuf, version, spec, schema, snapshotID, entries)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
manifestBytes := manifestBuf.Bytes()
|
||||
if content == iceberg.ManifestContentDeletes {
|
||||
manifestBytes, err = patchManifestContentBytesToDeletes(manifestBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rebuilt := iceberg.NewManifestFile(version, filename, int64(len(manifestBytes)), int32(spec.ID()), snapshotID).
|
||||
Content(content).
|
||||
AddedFiles(mf.AddedDataFiles()).
|
||||
ExistingFiles(mf.ExistingDataFiles()).
|
||||
DeletedFiles(mf.DeletedDataFiles()).
|
||||
AddedRows(mf.AddedRows()).
|
||||
ExistingRows(mf.ExistingRows()).
|
||||
DeletedRows(mf.DeletedRows()).
|
||||
Partitions(mf.Partitions()).
|
||||
Build()
|
||||
return rebuilt, manifestBytes, nil
|
||||
}
|
||||
|
||||
func patchManifestContentBytesToDeletes(manifestBytes []byte) ([]byte, error) {
|
||||
old := append([]byte{0x0e}, []byte("content")...)
|
||||
old = append(old, 0x08)
|
||||
old = append(old, []byte("data")...)
|
||||
|
||||
new := append([]byte{0x0e}, []byte("content")...)
|
||||
new = append(new, 0x0e)
|
||||
new = append(new, []byte("deletes")...)
|
||||
|
||||
result := bytes.Replace(manifestBytes, old, new, 1)
|
||||
if bytes.Equal(result, manifestBytes) {
|
||||
return nil, fmt.Errorf("delete manifest content patch failed")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func writePositionDeleteFile(rows []positionDeleteRow) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewWriter(&buf, parquet.SchemaOf(new(positionDeleteRow)))
|
||||
for _, row := range rows {
|
||||
if err := writer.Write(&row); err != nil {
|
||||
return nil, fmt.Errorf("write position delete row: %w", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close position delete file: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (h *Handler) rewritePositionDeleteFiles(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
meta, dataPath := state.Metadata, state.DataPath
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return "no current snapshot", nil, nil
|
||||
}
|
||||
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
manifests, err := s3tables.ReadManifestList(manifestListData)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
|
||||
specs := specByID(meta)
|
||||
schema := meta.CurrentSchema()
|
||||
|
||||
var dataManifests []iceberg.ManifestFile
|
||||
var allEqualityEntries []iceberg.ManifestEntry
|
||||
for _, mf := range manifests {
|
||||
switch mf.ManifestContent() {
|
||||
case iceberg.ManifestContentData:
|
||||
dataManifests = append(dataManifests, mf)
|
||||
case iceberg.ManifestContentDeletes:
|
||||
manifestData, readErr := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
|
||||
if readErr != nil {
|
||||
return "", nil, fmt.Errorf("read delete manifest %s: %w", mf.FilePath(), readErr)
|
||||
}
|
||||
entries, parseErr := s3tables.ReadManifest(mf, manifestData, true, specs, schema)
|
||||
if parseErr != nil {
|
||||
return "", nil, fmt.Errorf("parse delete manifest %s: %w", mf.FilePath(), parseErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.DataFile().ContentType() == iceberg.EntryContentEqDeletes {
|
||||
allEqualityEntries = append(allEqualityEntries, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupMap, allPositionEntries, err := collectDeleteRewriteGroups(ctx, filerClient, bucketName, dataPath, manifests, specs, schema)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(groupMap) == 0 {
|
||||
return "no position delete files eligible for rewrite", nil, nil
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
dir, fileName string
|
||||
}
|
||||
var writtenArtifacts []artifact
|
||||
committed := false
|
||||
defer func() {
|
||||
if committed || len(writtenArtifacts) == 0 {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, a := range writtenArtifacts {
|
||||
if err := deleteFilerFile(cleanupCtx, filerClient, a.dir, a.fileName); err != nil {
|
||||
glog.Warningf("iceberg delete rewrite: failed to clean up artifact %s/%s: %v", a.dir, a.fileName, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
specByID := specByID(meta)
|
||||
predicate, err := parsePartitionPredicate(config.Where, meta)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
type specEntries struct {
|
||||
specID int32
|
||||
entries []iceberg.ManifestEntry
|
||||
}
|
||||
specEntriesMap := make(map[int32]*specEntries)
|
||||
addToSpec := func(specID int32, entry iceberg.ManifestEntry) {
|
||||
se, ok := specEntriesMap[specID]
|
||||
if !ok {
|
||||
se = &specEntries{specID: specID}
|
||||
specEntriesMap[specID] = se
|
||||
}
|
||||
se.entries = append(se.entries, entry)
|
||||
}
|
||||
|
||||
newSnapID := time.Now().UnixMilli()
|
||||
version := meta.Version()
|
||||
snapshotID := currentSnap.SnapshotID
|
||||
seqNum := currentSnap.SequenceNumber + 1
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "metadata")
|
||||
dataDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "data")
|
||||
artifactSuffix := compactRandomSuffix()
|
||||
|
||||
replacedPaths := make(map[string]struct{})
|
||||
var summary snapshotSummary
|
||||
var rewrittenGroups int64
|
||||
var skippedGroups int64
|
||||
var deleteFilesRewritten int64
|
||||
var deleteFilesWritten int64
|
||||
var deleteBytesRewritten int64
|
||||
|
||||
sortedKeys := make([]string, 0, len(groupMap))
|
||||
for key := range groupMap {
|
||||
sortedKeys = append(sortedKeys, key)
|
||||
}
|
||||
sort.Strings(sortedKeys)
|
||||
|
||||
for _, key := range sortedKeys {
|
||||
group := groupMap[key]
|
||||
if predicate != nil {
|
||||
spec, ok := specByID[int(group.SpecID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
match, err := predicate.Matches(spec, group.Partition)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !match {
|
||||
skippedGroups++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !groupEligibleForRewrite(group, config) {
|
||||
skippedGroups++
|
||||
continue
|
||||
}
|
||||
rows := make([]positionDeleteRow, 0)
|
||||
for _, input := range group.Inputs {
|
||||
for _, pos := range input.Positions {
|
||||
rows = append(rows, positionDeleteRow{FilePath: input.ReferencedPath, Pos: pos})
|
||||
}
|
||||
replacedPaths[input.Entry.DataFile().FilePath()] = struct{}{}
|
||||
deleteFilesRewritten++
|
||||
deleteBytesRewritten += input.Entry.DataFile().FileSizeBytes()
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].FilePath != rows[j].FilePath {
|
||||
return rows[i].FilePath < rows[j].FilePath
|
||||
}
|
||||
return rows[i].Pos < rows[j].Pos
|
||||
})
|
||||
|
||||
outputFiles := estimatedDeleteOutputFiles(group.TotalSize, config.DeleteTargetFileSizeBytes)
|
||||
rowsPerFile := (len(rows) + outputFiles - 1) / outputFiles
|
||||
if rowsPerFile < 1 {
|
||||
rowsPerFile = len(rows)
|
||||
}
|
||||
|
||||
for startIdx, fileIdx := 0, 0; startIdx < len(rows); startIdx, fileIdx = startIdx+rowsPerFile, fileIdx+1 {
|
||||
endIdx := startIdx + rowsPerFile
|
||||
if endIdx > len(rows) {
|
||||
endIdx = len(rows)
|
||||
}
|
||||
outputRows := rows[startIdx:endIdx]
|
||||
deleteBytes, err := writePositionDeleteFile(outputRows)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
fileName := fmt.Sprintf("rewrite-delete-%d-%s-%d.parquet", newSnapID, artifactSuffix, deleteFilesWritten)
|
||||
if err := ensureFilerDir(ctx, filerClient, dataDir); err != nil {
|
||||
return "", nil, fmt.Errorf("ensure data dir: %w", err)
|
||||
}
|
||||
if err := saveFilerFile(ctx, filerClient, dataDir, fileName, deleteBytes); err != nil {
|
||||
return "", nil, fmt.Errorf("save rewritten delete file: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: dataDir, fileName: fileName})
|
||||
|
||||
spec, ok := specByID[int(group.SpecID)]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("partition spec %d not found", group.SpecID)
|
||||
}
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentPosDeletes,
|
||||
absoluteIcebergPath(bucketName, dataPath, "data", fileName),
|
||||
iceberg.ParquetFile,
|
||||
group.Partition,
|
||||
nil, nil,
|
||||
int64(len(outputRows)),
|
||||
int64(len(deleteBytes)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("build rewritten delete file: %w", err)
|
||||
}
|
||||
rewrittenDeleteFile := dfBuilder.Build()
|
||||
summary.addFile(rewrittenDeleteFile)
|
||||
addToSpec(group.SpecID, iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &newSnapID, nil, nil, rewrittenDeleteFile))
|
||||
deleteFilesWritten++
|
||||
}
|
||||
|
||||
for _, input := range group.Inputs {
|
||||
summary.removeFile(input.Entry.DataFile())
|
||||
delEntry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusDELETED,
|
||||
&newSnapID,
|
||||
manifestEntrySeqNum(input.Entry),
|
||||
manifestEntryFileSeqNum(input.Entry),
|
||||
input.Entry.DataFile(),
|
||||
)
|
||||
addToSpec(group.SpecID, delEntry)
|
||||
}
|
||||
rewrittenGroups++
|
||||
}
|
||||
|
||||
if rewrittenGroups == 0 {
|
||||
return "no position delete files eligible for rewrite", nil, nil
|
||||
}
|
||||
|
||||
for _, entry := range allEqualityEntries {
|
||||
existingEntry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusEXISTING,
|
||||
func() *int64 { id := entry.SnapshotID(); return &id }(),
|
||||
manifestEntrySeqNum(entry),
|
||||
manifestEntryFileSeqNum(entry),
|
||||
entry.DataFile(),
|
||||
)
|
||||
addToSpec(entry.DataFile().SpecID(), existingEntry)
|
||||
}
|
||||
|
||||
for _, entry := range allPositionEntries {
|
||||
if _, replaced := replacedPaths[entry.DataFile().FilePath()]; replaced {
|
||||
continue
|
||||
}
|
||||
existingEntry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusEXISTING,
|
||||
func() *int64 { id := entry.SnapshotID(); return &id }(),
|
||||
manifestEntrySeqNum(entry),
|
||||
manifestEntryFileSeqNum(entry),
|
||||
entry.DataFile(),
|
||||
)
|
||||
addToSpec(entry.DataFile().SpecID(), existingEntry)
|
||||
}
|
||||
|
||||
sortedSpecIDs := make([]int32, 0, len(specEntriesMap))
|
||||
for specID := range specEntriesMap {
|
||||
sortedSpecIDs = append(sortedSpecIDs, specID)
|
||||
}
|
||||
sort.Slice(sortedSpecIDs, func(i, j int) bool { return sortedSpecIDs[i] < sortedSpecIDs[j] })
|
||||
|
||||
allManifests := make([]iceberg.ManifestFile, 0, len(dataManifests)+len(sortedSpecIDs))
|
||||
allManifests = append(allManifests, dataManifests...)
|
||||
|
||||
for _, specID := range sortedSpecIDs {
|
||||
spec, ok := specByID[int(specID)]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("partition spec %d not found", specID)
|
||||
}
|
||||
manifestName := fmt.Sprintf("rewrite-delete-%d-%s-spec%d.avro", newSnapID, artifactSuffix, specID)
|
||||
manifestPath := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestName)
|
||||
mf, manifestBytes, err := writeManifestWithContent(
|
||||
manifestPath,
|
||||
version,
|
||||
spec,
|
||||
meta.CurrentSchema(),
|
||||
newSnapID,
|
||||
specEntriesMap[specID].entries,
|
||||
iceberg.ManifestContentDeletes,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("write delete manifest for spec %d: %w", specID, err)
|
||||
}
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestName, manifestBytes); err != nil {
|
||||
return "", nil, fmt.Errorf("save delete manifest for spec %d: %w", specID, err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestName})
|
||||
allManifests = append(allManifests, mf)
|
||||
}
|
||||
|
||||
var manifestListBuf bytes.Buffer
|
||||
if err := iceberg.WriteManifestList(version, &manifestListBuf, newSnapID, &snapshotID, &seqNum, 0, allManifests); err != nil {
|
||||
return "", nil, fmt.Errorf("write delete manifest list: %w", err)
|
||||
}
|
||||
manifestListName := fmt.Sprintf("snap-%d-%s.avro", newSnapID, artifactSuffix)
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListName, manifestListBuf.Bytes()); err != nil {
|
||||
return "", nil, fmt.Errorf("save delete manifest list: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListName})
|
||||
|
||||
manifestListLocation := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestListName)
|
||||
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, state.MetadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if cs == nil || cs.SnapshotID != snapshotID {
|
||||
return errStalePlan
|
||||
}
|
||||
newSnapshot := &table.Snapshot{
|
||||
SnapshotID: newSnapID,
|
||||
ParentSnapshotID: &snapshotID,
|
||||
SequenceNumber: seqNum,
|
||||
TimestampMs: newSnapID,
|
||||
ManifestList: manifestListLocation,
|
||||
Summary: summary.build(table.OpReplace, cs, map[string]string{
|
||||
"maintenance": "rewrite_position_delete_files",
|
||||
"delete-files-rewritten": fmt.Sprintf("%d", deleteFilesRewritten),
|
||||
"delete-files-written": fmt.Sprintf("%d", deleteFilesWritten),
|
||||
"delete-groups": fmt.Sprintf("%d", rewrittenGroups),
|
||||
}),
|
||||
SchemaID: func() *int {
|
||||
id := meta.CurrentSchema().ID
|
||||
return &id
|
||||
}(),
|
||||
}
|
||||
if err := builder.AddSnapshot(newSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return builder.SetSnapshotRef(table.MainBranch, newSnapID, table.BranchRef)
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("commit delete rewrite: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
metrics := map[string]int64{
|
||||
MetricDeleteFilesRewritten: deleteFilesRewritten,
|
||||
MetricDeleteFilesWritten: deleteFilesWritten,
|
||||
MetricDeleteBytesRewritten: deleteBytesRewritten,
|
||||
MetricDeleteGroupsPlanned: rewrittenGroups,
|
||||
MetricDeleteGroupsSkipped: skippedGroups,
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"rewrote %d position delete files into %d across %d group(s)",
|
||||
deleteFilesRewritten,
|
||||
deleteFilesWritten,
|
||||
rewrittenGroups,
|
||||
), metrics, nil
|
||||
}
|
||||
538
weed/worker/tasks/iceberg/detection.go
Normal file
538
weed/worker/tasks/iceberg/detection.go
Normal file
@@ -0,0 +1,538 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
)
|
||||
|
||||
// tableInfo captures metadata about a table for detection/execution.
|
||||
type tableInfo struct {
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
TablePath string // namespace/tableName
|
||||
MetadataFileName string
|
||||
Metadata table.Metadata
|
||||
}
|
||||
|
||||
// scanTablesForMaintenance enumerates table buckets and their tables,
|
||||
// evaluating which ones need maintenance based on metadata thresholds.
|
||||
// When limit > 0 the scan stops after collecting limit+1 results so the
|
||||
// caller can determine whether more tables remain (HasMore).
|
||||
func (h *Handler) scanTablesForMaintenance(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
config Config,
|
||||
bucketFilter, namespaceFilter, tableFilter string,
|
||||
limit int,
|
||||
) ([]tableInfo, error) {
|
||||
var tables []tableInfo
|
||||
ops, err := parseOperations(config.Operations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse operations: %w", err)
|
||||
}
|
||||
|
||||
// Compile wildcard matchers once (nil = match all)
|
||||
bucketMatchers := wildcard.CompileWildcardMatchers(bucketFilter)
|
||||
nsMatchers := wildcard.CompileWildcardMatchers(namespaceFilter)
|
||||
tableMatchers := wildcard.CompileWildcardMatchers(tableFilter)
|
||||
|
||||
bucketsPath := s3tables.TablesPath
|
||||
bucketEntries, err := listFilerEntries(ctx, filerClient, bucketsPath, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list buckets: %w", err)
|
||||
}
|
||||
|
||||
for _, bucketEntry := range bucketEntries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return tables, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if !bucketEntry.IsDirectory || !s3tables.IsTableBucketEntry(bucketEntry) {
|
||||
continue
|
||||
}
|
||||
bucketName := bucketEntry.Name
|
||||
if !wildcard.MatchesAnyWildcard(bucketMatchers, bucketName) {
|
||||
continue
|
||||
}
|
||||
bucketMaintenance, err := parseMaintenanceConfiguration(bucketEntry.Extended, bucketName)
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: skipping bucket %s: %v", bucketName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// List namespaces within the bucket
|
||||
bucketPath := path.Join(bucketsPath, bucketName)
|
||||
nsEntries, err := listFilerEntries(ctx, filerClient, bucketPath, "")
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to list namespaces in bucket %s: %v", bucketName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, nsEntry := range nsEntries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return tables, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if !nsEntry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
nsName := nsEntry.Name
|
||||
if !wildcard.MatchesAnyWildcard(nsMatchers, nsName) {
|
||||
continue
|
||||
}
|
||||
// Skip internal directories
|
||||
if strings.HasPrefix(nsName, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
// List tables within the namespace
|
||||
nsPath := path.Join(bucketPath, nsName)
|
||||
tableEntries, err := listFilerEntries(ctx, filerClient, nsPath, "")
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to list tables in %s/%s: %v", bucketName, nsName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tableEntry := range tableEntries {
|
||||
if !tableEntry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
tblName := tableEntry.Name
|
||||
if !wildcard.MatchesAnyWildcard(tableMatchers, tblName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this entry has table metadata
|
||||
metadataBytes, ok := tableEntry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok || len(metadataBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tablePath := path.Join(nsName, tblName)
|
||||
state, err := parseTableMetadataEnvelope(metadataBytes, bucketName, tablePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, errForeignFormat) {
|
||||
glog.V(3).Infof("iceberg maintenance: skipping %s/%s/%s: %v", bucketName, nsName, tblName, err)
|
||||
} else {
|
||||
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot parse iceberg metadata: %v", bucketName, nsName, tblName, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isIcebergTableEntry(tableEntry.Extended, state.Metadata) {
|
||||
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: not an iceberg table", bucketName, nsName, tblName)
|
||||
continue
|
||||
}
|
||||
|
||||
tableMaintenance, err := parseMaintenanceConfiguration(tableEntry.Extended, path.Join(bucketName, tablePath))
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: skipping %s/%s/%s: %v", bucketName, nsName, tblName, err)
|
||||
continue
|
||||
}
|
||||
maintenance := mergeMaintenanceConfiguration(bucketMaintenance, tableMaintenance)
|
||||
tableOps := filterDisabledOperations(ops, maintenance)
|
||||
if len(tableOps) == 0 {
|
||||
continue
|
||||
}
|
||||
effective := resolveTableConfig(config, state.Metadata.Properties(), maintenance)
|
||||
|
||||
needsWork, err := h.tableNeedsMaintenance(ctx, filerClient, bucketName, tablePath, state, effective, tableOps)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot evaluate maintenance need: %v", bucketName, nsName, tblName, err)
|
||||
continue
|
||||
}
|
||||
if needsWork {
|
||||
tables = append(tables, tableInfo{
|
||||
BucketName: bucketName,
|
||||
Namespace: nsName,
|
||||
TableName: tblName,
|
||||
TablePath: tablePath,
|
||||
MetadataFileName: state.MetadataFileName,
|
||||
Metadata: state.Metadata,
|
||||
})
|
||||
if limit > 0 && len(tables) > limit {
|
||||
return tables, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func normalizeDetectionConfig(config Config) Config {
|
||||
config = applyThresholdDefaults(config)
|
||||
if config.SnapshotRetentionMs <= 0 {
|
||||
config.SnapshotRetentionMs = hoursToMs(defaultSnapshotRetentionHours)
|
||||
}
|
||||
if config.MaxSnapshotsToKeep <= 0 {
|
||||
config.MaxSnapshotsToKeep = defaultMaxSnapshotsToKeep
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func (h *Handler) tableNeedsMaintenance(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
state *tableState,
|
||||
config Config,
|
||||
ops []string,
|
||||
) (bool, error) {
|
||||
config = normalizeDetectionConfig(config)
|
||||
meta := state.Metadata
|
||||
dataPath := state.DataPath
|
||||
metadataFileName := state.MetadataFileName
|
||||
cachedPlanningIndex := state.PlanningIndex
|
||||
|
||||
var predicate *partitionPredicate
|
||||
if strings.TrimSpace(config.Where) != "" {
|
||||
needsPredicate := false
|
||||
for _, op := range ops {
|
||||
if op == "compact" || op == "rewrite_position_delete_files" || op == "rewrite_manifests" {
|
||||
needsPredicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsPredicate {
|
||||
var err error
|
||||
predicate, err = parsePartitionPredicate(config.Where, meta)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = predicate // used by rewrite_position_delete_files; planning index handles compact/rewrite_manifests
|
||||
|
||||
// Evaluate the metadata-only expiration check first so large tables do not
|
||||
// pay for manifest reads when snapshot expiry already makes them eligible.
|
||||
for _, op := range ops {
|
||||
if op == "expire_snapshots" && needsMaintenance(meta, config) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
var currentManifests []iceberg.ManifestFile
|
||||
var manifestsErr error
|
||||
var manifestsLoaded bool
|
||||
getCurrentManifests := func() ([]iceberg.ManifestFile, error) {
|
||||
if manifestsLoaded {
|
||||
return currentManifests, manifestsErr
|
||||
}
|
||||
currentManifests, manifestsErr = loadCurrentManifests(ctx, filerClient, bucketName, dataPath, meta)
|
||||
manifestsLoaded = true
|
||||
return currentManifests, manifestsErr
|
||||
}
|
||||
|
||||
computedPlanningIndexes := make(map[string]*planningIndex)
|
||||
planningIndexLoaded := make(map[string]bool)
|
||||
planningIndexErrs := make(map[string]error)
|
||||
getPlanningIndex := func(op string) (*planningIndex, error) {
|
||||
if planningIndexLoaded[op] {
|
||||
return computedPlanningIndexes[op], planningIndexErrs[op]
|
||||
}
|
||||
planningIndexLoaded[op] = true
|
||||
|
||||
manifests, err := getCurrentManifests()
|
||||
if err != nil {
|
||||
planningIndexErrs[op] = err
|
||||
return nil, err
|
||||
}
|
||||
index, err := buildPlanningIndexFromManifests(ctx, filerClient, bucketName, dataPath, meta, config, []string{op}, manifests)
|
||||
if err != nil {
|
||||
planningIndexErrs[op] = err
|
||||
return nil, err
|
||||
}
|
||||
computedPlanningIndexes[op] = index
|
||||
if index != nil {
|
||||
if err := persistPlanningIndex(ctx, filerClient, bucketName, tablePath, index); err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: unable to persist planning index for %s/%s: %v", bucketName, tablePath, err)
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
checkPlanningIndex := func(op string, eligibleFn func(*planningIndex, Config) (bool, bool)) (bool, error) {
|
||||
if cachedPlanningIndex != nil && cachedPlanningIndex.matchesSnapshot(meta) {
|
||||
if eligible, ok := eligibleFn(cachedPlanningIndex, config); ok {
|
||||
return eligible, nil
|
||||
}
|
||||
}
|
||||
|
||||
index, err := getPlanningIndex(op)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if index == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
eligible, _ := eligibleFn(index, config)
|
||||
return eligible, nil
|
||||
}
|
||||
|
||||
var opEvalErrors []string
|
||||
planningIndexErrorReported := false
|
||||
|
||||
for _, op := range ops {
|
||||
switch op {
|
||||
case "expire_snapshots":
|
||||
// Handled by the metadata-only check above.
|
||||
continue
|
||||
case "compact":
|
||||
eligible, err := checkPlanningIndex(op, (*planningIndex).compactionEligible)
|
||||
if err != nil {
|
||||
if !planningIndexErrorReported {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
planningIndexErrorReported = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if eligible {
|
||||
return true, nil
|
||||
}
|
||||
case "rewrite_position_delete_files":
|
||||
manifests, err := getCurrentManifests()
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
eligible, err := hasEligibleDeleteRewrite(ctx, filerClient, bucketName, dataPath, manifests, config, meta, predicate)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if eligible {
|
||||
return true, nil
|
||||
}
|
||||
case "rewrite_manifests":
|
||||
eligible, err := checkPlanningIndex(op, (*planningIndex).rewriteManifestsEligible)
|
||||
if err != nil {
|
||||
if !planningIndexErrorReported {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
planningIndexErrorReported = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if eligible {
|
||||
return true, nil
|
||||
}
|
||||
case "remove_orphans":
|
||||
if metadataFileName == "" {
|
||||
currentState, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
metadataFileName = currentState.MetadataFileName
|
||||
}
|
||||
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, dataPath, meta, metadataFileName, config.OrphanOlderThanHours)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if len(orphanCandidates) > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(opEvalErrors) > 0 {
|
||||
return false, fmt.Errorf("evaluate maintenance operations: %s", strings.Join(opEvalErrors, "; "))
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func metadataFileNameFromLocation(location string) string {
|
||||
if location == "" {
|
||||
return ""
|
||||
}
|
||||
name := path.Base(location)
|
||||
if name == "." || name == "/" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func countDataManifests(manifests []iceberg.ManifestFile) int64 {
|
||||
var count int64
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() == iceberg.ManifestContentData {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func loadCurrentManifests(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
meta table.Metadata,
|
||||
) ([]iceberg.ManifestFile, error) {
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
manifests, err := s3tables.ReadManifestList(manifestListData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
return manifests, nil
|
||||
}
|
||||
|
||||
func hasEligibleCompaction(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
manifests []iceberg.ManifestFile,
|
||||
config Config,
|
||||
meta table.Metadata,
|
||||
predicate *partitionPredicate,
|
||||
) (bool, error) {
|
||||
if len(manifests) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
minInputFiles, err := compactionMinInputFiles(config.MinInputFiles)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var dataManifests []iceberg.ManifestFile
|
||||
specIDs := make(map[int32]struct{})
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
dataManifests = append(dataManifests, mf)
|
||||
specIDs[mf.PartitionSpecID()] = struct{}{}
|
||||
}
|
||||
if len(dataManifests) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(specIDs) > 1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
specsByID := specByID(meta)
|
||||
schema := meta.CurrentSchema()
|
||||
|
||||
var allEntries []iceberg.ManifestEntry
|
||||
for _, mf := range dataManifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := s3tables.ReadManifest(mf, manifestData, true, specsByID, schema)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
allEntries = append(allEntries, entries...)
|
||||
}
|
||||
|
||||
candidateEntries := allEntries
|
||||
if predicate != nil {
|
||||
candidateEntries = make([]iceberg.ManifestEntry, 0, len(allEntries))
|
||||
for _, entry := range allEntries {
|
||||
spec, ok := specsByID[int(entry.DataFile().SpecID())]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
match, err := predicate.Matches(spec, entry.DataFile().Partition())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if match {
|
||||
candidateEntries = append(candidateEntries, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rewritePlan, err := resolveCompactionRewritePlan(config, meta)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve rewrite strategy: %w", err)
|
||||
}
|
||||
|
||||
targetSize := compactionTargetSizeForPlan(config, rewritePlan)
|
||||
bins := buildCompactionBins(candidateEntries, targetSize, minInputFiles)
|
||||
bins = filterCompactionBinsByPlan(bins, config, rewritePlan)
|
||||
return len(bins) > 0, nil
|
||||
}
|
||||
|
||||
func compactionMinInputFiles(minInputFiles int64) (int, error) {
|
||||
// Ensure the configured value is positive and fits into the platform's int type
|
||||
if minInputFiles <= 0 {
|
||||
return 0, fmt.Errorf("min input files must be positive, got %d", minInputFiles)
|
||||
}
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if minInputFiles > maxInt {
|
||||
return 0, fmt.Errorf("min input files %d exceeds platform int size", minInputFiles)
|
||||
}
|
||||
return int(minInputFiles), nil
|
||||
}
|
||||
|
||||
// needsMaintenance checks whether snapshot expiration work is needed based on
|
||||
// metadata-only thresholds.
|
||||
// It asks execution what it would do rather than reimplementing the rules:
|
||||
// expiry always requires a snapshot to be past the retention window, so a
|
||||
// table over the snapshot quota whose snapshots are all young, or all pinned by
|
||||
// refs, would otherwise be proposed for a job that can only no-op.
|
||||
func needsMaintenance(meta table.Metadata, config Config) bool {
|
||||
return len(snapshotsToExpire(meta, config, time.Now().UnixMilli())) > 0
|
||||
}
|
||||
|
||||
// buildMaintenanceProposal creates a JobProposal for a table needing maintenance.
|
||||
func (h *Handler) buildMaintenanceProposal(t tableInfo, filerGrpcAddress, resourceGroup string) *plugin_pb.JobProposal {
|
||||
dedupeKey := fmt.Sprintf("iceberg_maintenance:%s/%s/%s", t.BucketName, t.Namespace, t.TableName)
|
||||
|
||||
snapshotCount := len(t.Metadata.Snapshots())
|
||||
summary := fmt.Sprintf("Maintain %s/%s/%s (%d snapshots)", t.BucketName, t.Namespace, t.TableName, snapshotCount)
|
||||
|
||||
proposal := &plugin_pb.JobProposal{
|
||||
ProposalId: fmt.Sprintf("iceberg-%s-%s-%s-%d", t.BucketName, t.Namespace, t.TableName, time.Now().UnixMilli()),
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: jobType,
|
||||
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
|
||||
Summary: summary,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.BucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.Namespace}},
|
||||
"table_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.TableName}},
|
||||
"table_path": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.TablePath}},
|
||||
"filer_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: filerGrpcAddress}},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"bucket": t.BucketName,
|
||||
"namespace": t.Namespace,
|
||||
"table": t.TableName,
|
||||
},
|
||||
}
|
||||
if resourceGroup != "" {
|
||||
proposal.Parameters["resource_group"] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_StringValue{StringValue: resourceGroup}}
|
||||
proposal.Labels["resource_group"] = resourceGroup
|
||||
}
|
||||
return proposal
|
||||
}
|
||||
484
weed/worker/tasks/iceberg/filer_io.go
Normal file
484
weed/worker/tasks/iceberg/filer_io.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// filerFileEntry holds a non-directory entry with its full directory path.
|
||||
type filerFileEntry struct {
|
||||
Dir string
|
||||
Entry *filer_pb.Entry
|
||||
}
|
||||
|
||||
var initGlobalHTTPClientOnce sync.Once
|
||||
|
||||
type singleFilerClient struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (c singleFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c.client)
|
||||
}
|
||||
|
||||
func (c singleFilerClient) AdjustedUrl(location *filer_pb.Location) string {
|
||||
if location == nil {
|
||||
return ""
|
||||
}
|
||||
if location.PublicUrl != "" {
|
||||
return location.PublicUrl
|
||||
}
|
||||
return location.Url
|
||||
}
|
||||
|
||||
func (c singleFilerClient) GetDataCenter() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// listFilerEntries lists all entries in a directory.
|
||||
func listFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, prefix string) ([]*filer_pb.Entry, error) {
|
||||
var entries []*filer_pb.Entry
|
||||
var lastFileName string
|
||||
limit := uint32(10000)
|
||||
|
||||
for {
|
||||
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: dir,
|
||||
Prefix: prefix,
|
||||
StartFromFileName: lastFileName,
|
||||
InclusiveStartFrom: lastFileName == "",
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
// Treat not-found as empty directory; propagate other errors.
|
||||
if status.Code(err) == codes.NotFound {
|
||||
return entries, nil
|
||||
}
|
||||
return entries, fmt.Errorf("list entries in %s: %w", dir, err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for {
|
||||
entry, recvErr := resp.Recv()
|
||||
if recvErr != nil {
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return entries, fmt.Errorf("recv entry in %s: %w", dir, recvErr)
|
||||
}
|
||||
if entry.Entry != nil {
|
||||
entries = append(entries, entry.Entry)
|
||||
lastFileName = entry.Entry.Name
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count < int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// walkFilerEntries recursively lists all non-directory entries under dir.
|
||||
func walkFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string) ([]filerFileEntry, error) {
|
||||
entries, err := listFilerEntries(ctx, client, dir, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []filerFileEntry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDirectory {
|
||||
subDir := path.Join(dir, entry.Name)
|
||||
subEntries, err := walkFilerEntries(ctx, client, subDir)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", subDir, err)
|
||||
continue
|
||||
}
|
||||
result = append(result, subEntries...)
|
||||
} else {
|
||||
result = append(result, filerFileEntry{Dir: dir, Entry: entry})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadCurrentMetadata loads and parses the current Iceberg metadata from the table entry's xattr.
|
||||
func loadCurrentMetadata(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string) (*tableState, error) {
|
||||
dir := path.Join(s3tables.TablesPath, bucketName, path.Dir(tablePath))
|
||||
name := path.Base(tablePath)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup table entry %s/%s: %w", dir, name, err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return nil, fmt.Errorf("table entry not found: %s/%s", dir, name)
|
||||
}
|
||||
|
||||
metadataBytes, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok || len(metadataBytes) == 0 {
|
||||
return nil, fmt.Errorf("no metadata xattr on table entry %s/%s", dir, name)
|
||||
}
|
||||
|
||||
state, err := parseTableMetadataEnvelope(metadataBytes, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refuse to rewrite an entry that only looks like an Iceberg table. Every
|
||||
// operation here rewrites or deletes files under the table location, and a
|
||||
// foreign format's files are unreferenced by Iceberg metadata by definition.
|
||||
if !isIcebergTableEntry(resp.Entry.Extended, state.Metadata) {
|
||||
return nil, fmt.Errorf("entry %s/%s is not an iceberg table", dir, name)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// loadFileByIcebergPath loads a file from the filer given an Iceberg-style path.
|
||||
// dataPath is the bucket-relative directory the table's files live in.
|
||||
func loadFileByIcebergPath(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, dataPath, icebergPath string) ([]byte, error) {
|
||||
fullPath, err := icebergFilerPath(bucketName, dataPath, icebergPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, fileName := path.Dir(fullPath), path.Base(fullPath)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: fileName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return nil, fmt.Errorf("file not found: %s/%s", dir, fileName)
|
||||
}
|
||||
|
||||
if len(resp.Entry.Content) > 0 || len(resp.Entry.Chunks) == 0 {
|
||||
return resp.Entry.Content, nil
|
||||
}
|
||||
|
||||
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
|
||||
reader := filer.NewFileReader(singleFilerClient{client: client}, resp.Entry)
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read chunked file %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// normalizeIcebergPath converts an Iceberg path (an S3 URL, an absolute filer
|
||||
// path, or a path relative to the table root) into a path relative to the
|
||||
// bucket root, e.g. "ns/table/metadata/snap-1.avro". Absolute references are
|
||||
// resolved from the bucket root rather than from dataPath: an Iceberg table
|
||||
// records where its files actually are, and a client writing through the REST
|
||||
// catalog may place them outside the catalog path. The bucket-relative form is
|
||||
// the canonical key for comparing references that mix both spellings.
|
||||
func normalizeIcebergPath(icebergPath, bucketName, dataPath string) (string, error) {
|
||||
p := icebergPath
|
||||
absolute := false
|
||||
if idx := strings.Index(p, "://"); idx >= 0 {
|
||||
p = p[idx+3:]
|
||||
absolute = true
|
||||
} else if strings.HasPrefix(p, "/") {
|
||||
absolute = true
|
||||
}
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
|
||||
if absolute {
|
||||
rest, ok := cutPathPrefix(p, bucketName)
|
||||
if !ok {
|
||||
// Filer-style references carry the /buckets prefix ahead of the bucket.
|
||||
if withoutTablesPath, hasTablesPath := cutPathPrefix(p, strings.TrimPrefix(s3tables.TablesPath, "/")); hasTablesPath {
|
||||
rest, ok = cutPathPrefix(withoutTablesPath, bucketName)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("iceberg path %q is outside bucket %q", icebergPath, bucketName)
|
||||
}
|
||||
p = rest
|
||||
} else {
|
||||
if clean := path.Clean(p); clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", fmt.Errorf("invalid iceberg path %q", icebergPath)
|
||||
}
|
||||
p = path.Join(dataPath, p)
|
||||
}
|
||||
|
||||
p = path.Clean(p)
|
||||
if p == "." || p == ".." || strings.HasPrefix(p, "../") {
|
||||
return "", fmt.Errorf("invalid iceberg path %q", icebergPath)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// cutPathPrefix removes a leading path prefix at a path boundary.
|
||||
func cutPathPrefix(p, segment string) (string, bool) {
|
||||
if segment == "" {
|
||||
return p, false
|
||||
}
|
||||
rest, ok := strings.CutPrefix(p, segment+"/")
|
||||
return rest, ok
|
||||
}
|
||||
|
||||
// icebergFilerPath resolves an Iceberg path to the filer path holding the file.
|
||||
func icebergFilerPath(bucketName, dataPath, icebergPath string) (string, error) {
|
||||
relPath, err := normalizeIcebergPath(icebergPath, bucketName, dataPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path.Join(s3tables.TablesPath, bucketName, relPath), nil
|
||||
}
|
||||
|
||||
// absoluteIcebergPath is the inverse of normalizeIcebergPath: it builds the
|
||||
// absolute s3:// URI for a bucket-relative path. The Iceberg spec requires
|
||||
// absolute locations in metadata — strict readers (Spark/Trino via S3FileIO)
|
||||
// reject paths with no scheme.
|
||||
func absoluteIcebergPath(bucketName string, elem ...string) string {
|
||||
return "s3://" + path.Join(append([]string{bucketName}, elem...)...)
|
||||
}
|
||||
|
||||
const (
|
||||
// inlineContentLimit is the most saveFilerFile keeps in Entry.Content.
|
||||
// Manifests and metadata JSON are small and stay inline; a compacted data
|
||||
// file runs to hundreds of MB, and inlining that stores the parquet bytes
|
||||
// verbatim in the filer store and ships them again through the metadata
|
||||
// change log as one oversized event.
|
||||
inlineContentLimit = 256 * 1024
|
||||
// filerFileChunkSize is the upload chunk size for anything over the limit.
|
||||
filerFileChunkSize = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
// saveFilerFile saves a file to the filer, inline when it is small and as
|
||||
// volume chunks when it is not.
|
||||
func saveFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string, content []byte) error {
|
||||
entry := &filer_pb.Entry{
|
||||
Name: fileName,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0644),
|
||||
FileSize: uint64(len(content)),
|
||||
},
|
||||
}
|
||||
if len(content) <= inlineContentLimit {
|
||||
entry.Content = content
|
||||
} else {
|
||||
chunks, err := uploadFilerChunks(ctx, client, path.Join(dir, fileName), content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
entry.Chunks = chunks
|
||||
}
|
||||
|
||||
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: entry,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create entry %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("create entry %s/%s: %s", dir, fileName, resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadFilerChunks writes content to volume servers, assigning through the
|
||||
// filer so the entry's storage rules apply.
|
||||
func uploadFilerChunks(ctx context.Context, client filer_pb.SeaweedFilerClient, fullPath string, content []byte) ([]*filer_pb.FileChunk, error) {
|
||||
assignFn := func(ctx context.Context, count int, expectedDataSize uint64) (*operation.VolumeAssignRequest, *operation.AssignResult, error) {
|
||||
resp, err := client.AssignVolume(ctx, &filer_pb.AssignVolumeRequest{
|
||||
Count: int32(count),
|
||||
Path: fullPath,
|
||||
ExpectedDataSize: expectedDataSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return nil, nil, errors.New(resp.Error)
|
||||
}
|
||||
if resp.Location == nil || resp.FileId == "" {
|
||||
return nil, nil, fmt.Errorf("assign volume returned no location")
|
||||
}
|
||||
return nil, &operation.AssignResult{
|
||||
Fid: resp.FileId,
|
||||
Url: resp.Location.Url,
|
||||
PublicUrl: resp.Location.PublicUrl,
|
||||
Count: uint64(count),
|
||||
Auth: security.EncodedJwt(resp.Auth),
|
||||
Fsync: resp.Fsync,
|
||||
}, nil
|
||||
}
|
||||
|
||||
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
|
||||
result, err := operation.UploadReaderInChunks(ctx, util.NewBytesReader(content), &operation.ChunkedUploadOption{
|
||||
ChunkSize: filerFileChunkSize,
|
||||
AssignFunc: assignFn,
|
||||
})
|
||||
if err != nil {
|
||||
// Chunks uploaded before the failure are orphaned: nothing references
|
||||
// them, so name them for fsck rather than losing them silently.
|
||||
if result != nil {
|
||||
for _, chunk := range result.FileChunks {
|
||||
glog.Warningf("iceberg: orphan chunk %s from failed upload of %s", chunk.GetFileIdString(), fullPath)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return result.FileChunks, nil
|
||||
}
|
||||
|
||||
// deleteFilerFile deletes a file from the filer.
|
||||
func deleteFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string) error {
|
||||
return filer_pb.DoRemove(ctx, client, dir, fileName, true, false, true, false, nil)
|
||||
}
|
||||
|
||||
// updateTableMetadataXattr updates the table entry's metadata xattr with
|
||||
// the new Iceberg metadata. It verifies the stored metadataVersion before
|
||||
// writing and passes the previous metadata xattr back to the filer as a
|
||||
// server-side precondition so concurrent writers fail with a retryable
|
||||
// metadata version conflict.
|
||||
// newMetadataLocation is the absolute location of the new metadata file
|
||||
// (e.g. "s3://bucket/ns/table/metadata/v3.metadata.json"), matching the form
|
||||
// the S3 Tables catalog stores.
|
||||
func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerClient, tableDir string, expectedVersion int, newFullMetadata []byte, newMetadataLocation string) error {
|
||||
tableName := path.Base(tableDir)
|
||||
parentDir := path.Dir(tableDir)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDir,
|
||||
Name: tableName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup table entry: %w", err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return fmt.Errorf("table entry not found")
|
||||
}
|
||||
|
||||
existingXattr, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok {
|
||||
return fmt.Errorf("no metadata xattr on table entry")
|
||||
}
|
||||
|
||||
// Parse existing xattr, update fullMetadata
|
||||
var internalMeta map[string]json.RawMessage
|
||||
if err := json.Unmarshal(existingXattr, &internalMeta); err != nil {
|
||||
return fmt.Errorf("unmarshal existing xattr: %w", err)
|
||||
}
|
||||
|
||||
// Verify the stored metadataVersion matches what we expect before issuing
|
||||
// the conditional UpdateEntry request below.
|
||||
versionRaw, ok := internalMeta["metadataVersion"]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: metadataVersion field missing from xattr", errMetadataVersionConflict)
|
||||
}
|
||||
var storedVersion int
|
||||
if err := json.Unmarshal(versionRaw, &storedVersion); err != nil {
|
||||
return fmt.Errorf("%w: cannot parse metadataVersion: %v", errMetadataVersionConflict, err)
|
||||
}
|
||||
if storedVersion != expectedVersion {
|
||||
return fmt.Errorf("%w: expected version %d, found %d", errMetadataVersionConflict, expectedVersion, storedVersion)
|
||||
}
|
||||
|
||||
// Update the metadata.fullMetadata field
|
||||
var metadataObj map[string]json.RawMessage
|
||||
if raw, ok := internalMeta["metadata"]; ok {
|
||||
if err := json.Unmarshal(raw, &metadataObj); err != nil {
|
||||
return fmt.Errorf("unmarshal metadata object: %w", err)
|
||||
}
|
||||
} else {
|
||||
metadataObj = make(map[string]json.RawMessage)
|
||||
}
|
||||
metadataObj["fullMetadata"] = newFullMetadata
|
||||
metadataJSON, err := json.Marshal(metadataObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata object: %w", err)
|
||||
}
|
||||
internalMeta["metadata"] = metadataJSON
|
||||
|
||||
// Increment version
|
||||
newVersion := expectedVersion + 1
|
||||
versionJSON, _ := json.Marshal(newVersion)
|
||||
internalMeta["metadataVersion"] = versionJSON
|
||||
|
||||
// Update modifiedAt
|
||||
modifiedAt, _ := json.Marshal(time.Now().Format(time.RFC3339Nano))
|
||||
internalMeta["modifiedAt"] = modifiedAt
|
||||
|
||||
// Update metadataLocation to point to the new metadata file
|
||||
metaLocJSON, _ := json.Marshal(newMetadataLocation)
|
||||
internalMeta["metadataLocation"] = metaLocJSON
|
||||
|
||||
// Regenerate versionToken for consistency with the S3 Tables catalog
|
||||
tokenJSON, _ := json.Marshal(generateIcebergVersionToken())
|
||||
internalMeta["versionToken"] = tokenJSON
|
||||
|
||||
updatedXattr, err := json.Marshal(internalMeta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal updated xattr: %w", err)
|
||||
}
|
||||
|
||||
// Assert the whole attribute set, not just the version: this rewrites the
|
||||
// entry from the snapshot above, so a narrower precondition would delete an
|
||||
// attribute a concurrent writer created, such as a maintenance configuration.
|
||||
expectedExtended := s3tables.SnapshotExtended(resp.Entry.Extended)
|
||||
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = updatedXattr
|
||||
resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion] = metadataVersionXattr(newVersion)
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDir,
|
||||
Entry: resp.Entry,
|
||||
ExpectedExtended: expectedExtended,
|
||||
})
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.FailedPrecondition {
|
||||
return fmt.Errorf("%w: table metadata changed during update", errMetadataVersionConflict)
|
||||
}
|
||||
return fmt.Errorf("update table entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func metadataVersionXattr(version int) []byte {
|
||||
return []byte(strconv.Itoa(version))
|
||||
}
|
||||
|
||||
// generateIcebergVersionToken produces a random hex token, mirroring the
|
||||
// logic in s3tables.generateVersionToken (which is unexported).
|
||||
func generateIcebergVersionToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
170
weed/worker/tasks/iceberg/filer_io_save_test.go
Normal file
170
weed/worker/tasks/iceberg/filer_io_save_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
// fakeVolumeServer accepts chunk uploads and records what it received, so a
|
||||
// test can check the bytes actually left the filer entry.
|
||||
type fakeVolumeServer struct {
|
||||
mu sync.Mutex
|
||||
server *httptest.Server
|
||||
parts map[string][]byte // fid → data
|
||||
}
|
||||
|
||||
func startFakeVolumeServer(t *testing.T) *fakeVolumeServer {
|
||||
t.Helper()
|
||||
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
|
||||
|
||||
v := &fakeVolumeServer{parts: make(map[string][]byte)}
|
||||
v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fid := strings.TrimPrefix(r.URL.Path, "/")
|
||||
// The uploader sends the payload as a single form-data part named
|
||||
// "file" with an empty filename, which FormFile will not match.
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
v.parts[fid] = data
|
||||
v.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"name": fid, "size": len(data)})
|
||||
}))
|
||||
t.Cleanup(v.server.Close)
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *fakeVolumeServer) hostPort(t *testing.T) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(v.server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fake volume url: %v", err)
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
func (v *fakeVolumeServer) uploaded() int {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return len(v.parts)
|
||||
}
|
||||
|
||||
func (v *fakeVolumeServer) dataFor(fid string) []byte {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.parts[fid]
|
||||
}
|
||||
|
||||
// AssignVolume on the fake filer hands out sequential fids pointing at the
|
||||
// fake volume server.
|
||||
func (f *fakeFilerServer) enableAssign(volumeServer string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.assignVolumeServer = volumeServer
|
||||
}
|
||||
|
||||
func TestSaveFilerFileKeepsSmallContentInline(t *testing.T) {
|
||||
fakeServer, client := startFakeFiler(t)
|
||||
|
||||
content := []byte(strings.Repeat("m", inlineContentLimit))
|
||||
if err := saveFilerFile(context.Background(), client, "/buckets/lake/ns/tbl/metadata", "snap.avro", content); err != nil {
|
||||
t.Fatalf("saveFilerFile: %v", err)
|
||||
}
|
||||
|
||||
entry := fakeServer.getEntry("/buckets/lake/ns/tbl/metadata", "snap.avro")
|
||||
if entry == nil {
|
||||
t.Fatal("entry not created")
|
||||
}
|
||||
if len(entry.Content) != len(content) {
|
||||
t.Errorf("inline content is %d bytes, want %d", len(entry.Content), len(content))
|
||||
}
|
||||
if len(entry.Chunks) != 0 {
|
||||
t.Errorf("small file got %d chunks, want inline only", len(entry.Chunks))
|
||||
}
|
||||
}
|
||||
|
||||
// A compacted data file must land in volumes, not in Entry.Content: an inline
|
||||
// entry that big is stored verbatim by the filer store and rides the metadata
|
||||
// change log as one event too large for a volume server to accept.
|
||||
func TestSaveFilerFileChunksLargeContent(t *testing.T) {
|
||||
fakeServer, client := startFakeFiler(t)
|
||||
volumeServer := startFakeVolumeServer(t)
|
||||
fakeServer.enableAssign(volumeServer.hostPort(t))
|
||||
|
||||
content := make([]byte, 3*filerFileChunkSize/2)
|
||||
for i := range content {
|
||||
content[i] = byte(i)
|
||||
}
|
||||
|
||||
if err := saveFilerFile(context.Background(), client, "/buckets/lake/ns/tbl/data", "compact-1.parquet", content); err != nil {
|
||||
t.Fatalf("saveFilerFile: %v", err)
|
||||
}
|
||||
|
||||
entry := fakeServer.getEntry("/buckets/lake/ns/tbl/data", "compact-1.parquet")
|
||||
if entry == nil {
|
||||
t.Fatal("entry not created")
|
||||
}
|
||||
if len(entry.Content) != 0 {
|
||||
t.Errorf("large file kept %d bytes inline, want none", len(entry.Content))
|
||||
}
|
||||
if len(entry.Chunks) != 2 {
|
||||
t.Fatalf("got %d chunks, want 2", len(entry.Chunks))
|
||||
}
|
||||
if entry.Attributes.FileSize != uint64(len(content)) {
|
||||
t.Errorf("file size %d, want %d", entry.Attributes.FileSize, len(content))
|
||||
}
|
||||
if volumeServer.uploaded() != 2 {
|
||||
t.Errorf("volume server received %d uploads, want 2", volumeServer.uploaded())
|
||||
}
|
||||
|
||||
// Reassembling the chunks in offset order gives back the original bytes.
|
||||
rejoined := make([]byte, len(content))
|
||||
for _, chunk := range entry.Chunks {
|
||||
data := volumeServer.dataFor(chunk.FileId)
|
||||
if uint64(len(data)) != chunk.Size {
|
||||
t.Fatalf("chunk %s holds %d bytes, entry says %d", chunk.FileId, len(data), chunk.Size)
|
||||
}
|
||||
copy(rejoined[chunk.Offset:], data)
|
||||
}
|
||||
if string(rejoined) != string(content) {
|
||||
t.Error("reassembled chunks differ from the original content")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed upload must not silently fall back to an inline entry.
|
||||
func TestSaveFilerFileFailsWhenUploadFails(t *testing.T) {
|
||||
fakeServer, client := startFakeFiler(t)
|
||||
|
||||
content := make([]byte, filerFileChunkSize+1)
|
||||
err := saveFilerFile(context.Background(), client, "/buckets/lake/ns/tbl/data", "compact-2.parquet", content)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when no volume can be assigned")
|
||||
}
|
||||
if entry := fakeServer.getEntry("/buckets/lake/ns/tbl/data", "compact-2.parquet"); entry != nil {
|
||||
t.Errorf("entry created despite the failed upload: %d inline bytes", len(entry.Content))
|
||||
}
|
||||
}
|
||||
113
weed/worker/tasks/iceberg/foreign_format_scan_test.go
Normal file
113
weed/worker/tasks/iceberg/foreign_format_scan_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
||||
)
|
||||
|
||||
// seedAdapterRegisteredLanceTable builds what the Lance namespace's Iceberg REST
|
||||
// adapter leaves behind: a real Iceberg table with a placeholder schema and
|
||||
// table_type=lance, whose directory holds a Lance dataset rather than Iceberg
|
||||
// data files.
|
||||
func seedAdapterRegisteredLanceTable(t *testing.T, properties iceberg.Properties) (*s3tablestest.MemFiler, string) {
|
||||
t.Helper()
|
||||
const bucket, namespace, table = "vectors", "ml", "embeddings"
|
||||
|
||||
filer := s3tablestest.Start(t)
|
||||
filer.Put(s3tables.TablesPath, bucket, map[string][]byte{s3tables.ExtendedKeyTableBucket: []byte("{}")})
|
||||
|
||||
nsMeta, err := json.Marshal(map[string]any{"namespace": []string{namespace}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal namespace metadata: %v", err)
|
||||
}
|
||||
filer.Put(s3tables.GetTableBucketPath(bucket), namespace, map[string][]byte{s3tables.ExtendedKeyMetadata: nsMeta})
|
||||
|
||||
full, err := json.Marshal(buildTestMetadata(t, nil, nil, 0, properties, nil, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal iceberg metadata: %v", err)
|
||||
}
|
||||
envelope, err := json.Marshal(map[string]any{
|
||||
"metadataVersion": 1,
|
||||
"metadataLocation": "s3://" + bucket + "/" + namespace + "/" + table + "/metadata/v1.metadata.json",
|
||||
"metadata": map[string]any{"fullMetadata": json.RawMessage(full)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal envelope: %v", err)
|
||||
}
|
||||
filer.Put(s3tables.GetNamespacePath(bucket, namespace), table,
|
||||
map[string][]byte{s3tables.ExtendedKeyMetadata: envelope})
|
||||
|
||||
// The Lance dataset. Its fragments live in data/, the same directory the
|
||||
// orphan cleaner walks, and the Iceberg metadata references none of them.
|
||||
tablePath := s3tables.GetTablePath(bucket, namespace, table)
|
||||
old := time.Now().Add(-30 * 24 * time.Hour)
|
||||
filer.Put(tablePath, "data", nil)
|
||||
filer.PutFile(path.Join(tablePath, "data"), "01111110101110001101011164cadc43919eace9c608107bd9.lance", old)
|
||||
filer.Put(tablePath, "_versions", nil)
|
||||
filer.PutFile(path.Join(tablePath, "_versions"), "1.manifest", old)
|
||||
filer.Put(tablePath, "metadata", nil)
|
||||
filer.PutFile(path.Join(tablePath, "metadata"), "v1.metadata.json", old)
|
||||
|
||||
return filer, path.Join(namespace, table)
|
||||
}
|
||||
|
||||
// The hazard this guards against, stated as a test: every fragment of a Lance
|
||||
// dataset is unreferenced by the Iceberg metadata sitting beside it, so orphan
|
||||
// cleanup would delete the whole dataset.
|
||||
func TestOrphanCleanupWouldDeleteAnAdapterRegisteredLanceDataset(t *testing.T) {
|
||||
filer, tablePath := seedAdapterRegisteredLanceTable(t, iceberg.Properties{tableTypeProperty: "lance"})
|
||||
|
||||
meta := buildTestMetadata(t, nil, nil, 0, iceberg.Properties{tableTypeProperty: "lance"}, nil, nil)
|
||||
candidates, err := collectOrphanCandidates(context.Background(), filer.Client, "vectors", tablePath,
|
||||
meta, "v1.metadata.json", defaultOrphanOlderThanHours)
|
||||
if err != nil {
|
||||
t.Fatalf("collect orphan candidates: %v", err)
|
||||
}
|
||||
|
||||
var doomed []string
|
||||
for _, c := range candidates {
|
||||
doomed = append(doomed, path.Join(c.Dir, c.Entry.Name))
|
||||
}
|
||||
if len(doomed) == 0 {
|
||||
t.Fatal("expected the Lance fragments to look like orphans; without that this guard is pointless")
|
||||
}
|
||||
for _, name := range doomed {
|
||||
if path.Ext(name) == ".lance" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected a .lance fragment among the orphan candidates, got %v", doomed)
|
||||
}
|
||||
|
||||
// And the guard that stops it: the scan never reaches the table.
|
||||
func TestScanSkipsAnAdapterRegisteredLanceTable(t *testing.T) {
|
||||
handler := &Handler{}
|
||||
config := normalizeDetectionConfig(Config{Operations: "all"})
|
||||
|
||||
filer, _ := seedAdapterRegisteredLanceTable(t, iceberg.Properties{tableTypeProperty: "lance"})
|
||||
tables, err := handler.scanTablesForMaintenance(context.Background(), filer.Client, config, "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if len(tables) != 0 {
|
||||
t.Fatalf("scan picked up a lance table: %v", tables)
|
||||
}
|
||||
|
||||
// An ordinary Iceberg table in the same shape is still scanned, so the guard
|
||||
// is not simply skipping everything.
|
||||
filer, _ = seedAdapterRegisteredLanceTable(t, nil)
|
||||
tables, err = handler.scanTablesForMaintenance(context.Background(), filer.Client, config, "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if len(tables) != 1 {
|
||||
t.Fatalf("scan skipped an iceberg table: %v", tables)
|
||||
}
|
||||
}
|
||||
73
weed/worker/tasks/iceberg/foreign_format_test.go
Normal file
73
weed/worker/tasks/iceberg/foreign_format_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// A Lance dataset registered through the Lance namespace's Iceberg REST adapter
|
||||
// arrives as an Iceberg table with a placeholder schema and table_type=lance,
|
||||
// and keeps its fragments under data/ where the orphan cleaner walks. Every
|
||||
// fragment is unreferenced by the Iceberg metadata, so a maintenance pass would
|
||||
// delete the dataset.
|
||||
func TestIsIcebergTableEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
extended map[string][]byte
|
||||
properties iceberg.Properties
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "plain table",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "iceberg table_type is honoured case-insensitively",
|
||||
properties: iceberg.Properties{tableTypeProperty: "ICEBERG"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "lance table registered through the iceberg adapter",
|
||||
properties: iceberg.Properties{tableTypeProperty: "lance"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "view",
|
||||
extended: map[string][]byte{s3tables.ExtendedKeyEntryType: []byte(s3tables.EntryTypeView)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "explicit table marker",
|
||||
extended: map[string][]byte{s3tables.ExtendedKeyEntryType: []byte(s3tables.EntryTypeTable)},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
meta := buildTestMetadata(t, nil, nil, 0, c.properties, nil, nil)
|
||||
if got := isIcebergTableEntry(c.extended, meta); got != c.want {
|
||||
t.Fatalf("isIcebergTableEntry() = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A table the namespace created as LANCE carries no Iceberg metadata at all.
|
||||
// Without reading the catalog's own format field the parse below just fails,
|
||||
// and the table gets skipped as though its metadata were damaged.
|
||||
func TestParseTableMetadataEnvelopeRejectsForeignFormats(t *testing.T) {
|
||||
lance := []byte(`{"name":"vectors","namespace":"ml","format":"LANCE","metadataLocation":"s3://b/ml/vectors"}`)
|
||||
if _, err := parseTableMetadataEnvelope(lance, "b", "ml/vectors"); !errors.Is(err, errForeignFormat) {
|
||||
t.Fatalf("parse of a LANCE entry = %v, want errForeignFormat", err)
|
||||
}
|
||||
|
||||
// An entry with no format recorded predates the field and is still Iceberg's.
|
||||
legacy := []byte(`{"metadataVersion":1}`)
|
||||
if _, err := parseTableMetadataEnvelope(legacy, "b", "ml/t"); err == nil || errors.Is(err, errForeignFormat) {
|
||||
t.Fatalf("parse of a legacy entry = %v, want a plain parse failure", err)
|
||||
}
|
||||
}
|
||||
739
weed/worker/tasks/iceberg/handler.go
Normal file
739
weed/worker/tasks/iceberg/handler.go
Normal file
@@ -0,0 +1,739 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
||||
JobType: jobType,
|
||||
Category: pluginworker.CategoryHeavy,
|
||||
Aliases: []string{"iceberg-maintenance", "iceberg.maintenance", "iceberg"},
|
||||
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
||||
return NewHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handler implements the JobHandler interface for Iceberg table maintenance:
|
||||
// snapshot expiration, orphan file removal, and manifest rewriting.
|
||||
type Handler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
const filerConnectTimeout = 5 * time.Second
|
||||
|
||||
// NewHandler creates a new handler for iceberg table maintenance.
|
||||
func NewHandler(grpcDialOption grpc.DialOption) *Handler {
|
||||
return &Handler{grpcDialOption: grpcDialOption}
|
||||
}
|
||||
|
||||
func (h *Handler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: jobType,
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 4,
|
||||
DisplayName: "Iceberg Maintenance",
|
||||
Description: "Compacts data, rewrites delete files, expires snapshots, removes orphans, and rewrites manifests for Iceberg tables in S3 table buckets",
|
||||
Weight: 50,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: jobType,
|
||||
DisplayName: "Iceberg Maintenance",
|
||||
Description: "Automated maintenance for Iceberg tables: data compaction, delete-file rewrite, snapshot expiration, orphan removal, and manifest rewriting",
|
||||
Icon: "fas fa-snowflake",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "iceberg-maintenance-admin",
|
||||
Title: "Iceberg Maintenance Admin Config",
|
||||
Description: "Admin-side controls for Iceberg table maintenance scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Filters to restrict which tables are scanned for maintenance.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "bucket_filter",
|
||||
Label: "Bucket Filter",
|
||||
Description: "Comma-separated wildcard patterns for table buckets (* and ? supported). Blank = all.",
|
||||
Placeholder: "prod-*, staging-*",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "namespace_filter",
|
||||
Label: "Namespace Filter",
|
||||
Description: "Comma-separated wildcard patterns for namespaces (* and ? supported). Blank = all.",
|
||||
Placeholder: "analytics, events-*",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "table_filter",
|
||||
Label: "Table Filter",
|
||||
Description: "Comma-separated wildcard patterns for table names (* and ? supported). Blank = all.",
|
||||
Placeholder: "clicks, orders-*",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "resources",
|
||||
Title: "Resource Groups",
|
||||
Description: "Controls for fair proposal distribution across buckets or namespaces.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "resource_group_by",
|
||||
Label: "Group Proposals By",
|
||||
Description: "When set, detection emits proposals in round-robin order across the selected resource group.",
|
||||
Placeholder: "none, bucket, namespace, or bucket_namespace",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "max_tables_per_resource_group",
|
||||
Label: "Max Tables Per Group",
|
||||
Description: "Optional cap on how many proposals a single resource group can receive in one detection run. Zero disables the cap.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"namespace_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"table_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"resource_group_by": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: resourceGroupNone}},
|
||||
"max_tables_per_resource_group": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "iceberg-maintenance-worker",
|
||||
Title: "Iceberg Maintenance Worker Config",
|
||||
Description: "Worker-side thresholds for maintenance operations.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "snapshots",
|
||||
Title: "Snapshot Expiration",
|
||||
Description: "Controls for automatic snapshot cleanup.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "snapshot_retention_hours",
|
||||
Label: "Retention (hours)",
|
||||
Description: "Expire snapshots older than this many hours.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "max_snapshots_to_keep",
|
||||
Label: "Max Snapshots",
|
||||
Description: "Always keep at least this many most recent snapshots.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "compaction",
|
||||
Title: "Data Compaction",
|
||||
Description: "Controls for bin-packing or sorting small Parquet data files.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "target_file_size_mb",
|
||||
Label: "Target File Size (MB)",
|
||||
Description: "Files smaller than this (in megabytes) are candidates for compaction.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "min_input_files",
|
||||
Label: "Min Input Files",
|
||||
Description: "Minimum number of small files in a partition to trigger compaction.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
{
|
||||
Name: "apply_deletes",
|
||||
Label: "Apply Deletes",
|
||||
Description: "When true, compaction applies position and equality deletes to data files. When false, tables with delete manifests are skipped.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_BOOL,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TOGGLE,
|
||||
},
|
||||
{
|
||||
Name: "rewrite_strategy",
|
||||
Label: "Rewrite Strategy",
|
||||
Description: "binpack keeps the existing row order; sort rewrites each compaction bin using the Iceberg table sort order; auto sorts tables that declare one and bin-packs the rest.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
Placeholder: "binpack or sort",
|
||||
},
|
||||
{
|
||||
Name: "sort_buffer_rows",
|
||||
Label: "Sort Buffer Rows",
|
||||
Description: "Rows a sorted rewrite holds in memory before spilling a sorted run to disk. The writer merges at most 32K runs, so this also bounds how many rows one output file can hold.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: minSortBufferRows}},
|
||||
},
|
||||
{
|
||||
Name: "sort_spill_dir",
|
||||
Label: "Sort Spill Directory",
|
||||
Description: "Directory holding a sorted rewrite's temporary runs. Empty uses the system temp directory.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "sort_max_input_mb",
|
||||
Label: "Sort Max Input (MB)",
|
||||
Description: "Optional hard cap for the total bytes in a sorted compaction bin. Zero = no extra cap beyond binning.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "delete_rewrite",
|
||||
Title: "Delete Rewrite",
|
||||
Description: "Controls for rewriting small position-delete files into fewer larger files.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "delete_target_file_size_mb",
|
||||
Label: "Delete Target File Size (MB)",
|
||||
Description: "Target size for rewritten position-delete files.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "delete_min_input_files",
|
||||
Label: "Delete Min Input Files",
|
||||
Description: "Minimum number of position-delete files in a group before rewrite is triggered.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
{
|
||||
Name: "delete_max_file_group_size_mb",
|
||||
Label: "Delete Max Group Size (MB)",
|
||||
Description: "Skip rewriting delete groups larger than this bound.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "delete_max_output_files",
|
||||
Label: "Delete Max Output Files",
|
||||
Description: "Maximum number of rewritten delete files a single group may produce.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "orphans",
|
||||
Title: "Orphan Removal",
|
||||
Description: "Controls for orphan file cleanup.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "orphan_older_than_hours",
|
||||
Label: "Safety Window (hours)",
|
||||
Description: "Only remove orphan files older than this many hours.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "manifests",
|
||||
Title: "Manifest Rewriting",
|
||||
Description: "Controls for merging small manifests.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "min_manifests_to_rewrite",
|
||||
Label: "Min Manifests",
|
||||
Description: "Minimum number of manifests before rewriting is triggered.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "general",
|
||||
Title: "General",
|
||||
Description: "General maintenance settings.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "max_commit_retries",
|
||||
Label: "Max Commit Retries",
|
||||
Description: "Maximum number of commit retries on version conflict.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 20}},
|
||||
},
|
||||
{
|
||||
Name: "operations",
|
||||
Label: "Operations",
|
||||
Description: "Comma-separated list of operations to run: compact, rewrite_position_delete_files, expire_snapshots, remove_orphans, rewrite_manifests, or 'all'.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "table_properties_override",
|
||||
Label: "Table Properties Win",
|
||||
Description: "Let a table's own Iceberg properties override these settings and its maintenance configuration. Clear to make the maintenance configuration authoritative.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_BOOL,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TOGGLE,
|
||||
},
|
||||
{
|
||||
Name: "where",
|
||||
Label: "Where Filter",
|
||||
Description: "Optional partition filter for compact, rewrite_position_delete_files, and rewrite_manifests. Supports field = literal, field IN (...), and AND.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
Placeholder: "region = 'us' AND dt IN ('2026-03-15')",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"delete_target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteTargetFileSizeMB}},
|
||||
"delete_min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMinInputFiles}},
|
||||
"delete_max_file_group_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxGroupSizeMB}},
|
||||
"delete_max_output_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxOutputFiles}},
|
||||
"min_manifests_to_rewrite": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinManifestsToRewrite}},
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
|
||||
"table_properties_override": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
|
||||
"rewrite_strategy": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultRewriteStrategy}},
|
||||
"sort_max_input_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
"sort_buffer_rows": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSortBufferRows}},
|
||||
"sort_spill_dir": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"where": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: false, // disabled by default
|
||||
DetectionIntervalMinutes: 60, // 1 hour
|
||||
DetectionTimeoutSeconds: 300,
|
||||
MaxJobsPerDetection: 100,
|
||||
GlobalExecutionConcurrency: 4,
|
||||
PerWorkerExecutionConcurrency: 2,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 60,
|
||||
JobTypeMaxRuntimeSeconds: 3600, // 1 hour max
|
||||
ExecutionTimeoutSeconds: 3600,
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"delete_target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteTargetFileSizeMB}},
|
||||
"delete_min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMinInputFiles}},
|
||||
"delete_max_file_group_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxGroupSizeMB}},
|
||||
"delete_max_output_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDeleteMaxOutputFiles}},
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
|
||||
"table_properties_override": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
|
||||
"rewrite_strategy": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultRewriteStrategy}},
|
||||
"sort_max_input_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
"sort_buffer_rows": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSortBufferRows}},
|
||||
"sort_spill_dir": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"where": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != jobType {
|
||||
return fmt.Errorf("job type %q is not handled by iceberg maintenance handler", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := ParseConfig(request.GetWorkerConfigValues())
|
||||
ops, err := parseOperations(workerConfig.Operations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid operations config: %w", err)
|
||||
}
|
||||
if err := validateWhereOperations(workerConfig.Where, ops); err != nil {
|
||||
return fmt.Errorf("invalid where config: %w", err)
|
||||
}
|
||||
|
||||
// Detection interval is managed by the scheduler via AdminRuntimeDefaults.DetectionIntervalMinutes.
|
||||
|
||||
// ClusterContext.FilerAddresses are pb.ServerAddress strings
|
||||
// (host:httpPort.grpcPort); collapse each to a dialable gRPC address.
|
||||
filerGrpcAddresses := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
for _, filer := range request.ClusterContext.FilerAddresses {
|
||||
filerGrpcAddresses = append(filerGrpcAddresses, pb.ServerAddress(filer).ToGrpcAddress())
|
||||
}
|
||||
}
|
||||
if len(filerGrpcAddresses) == 0 {
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no filer addresses in cluster context", nil))
|
||||
return h.sendEmptyDetection(sender)
|
||||
}
|
||||
|
||||
// Read scope filters
|
||||
bucketFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "bucket_filter", ""))
|
||||
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
|
||||
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
|
||||
resourceGroups, err := readResourceGroupConfig(request.GetAdminConfigValues())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin resource group config: %w", err)
|
||||
}
|
||||
|
||||
// Connect to filer — try each address until one succeeds.
|
||||
filerGrpcAddress, conn, err := h.connectToFiler(ctx, filerGrpcAddresses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||
|
||||
maxResults := int(request.MaxResults)
|
||||
scanLimit := maxResults
|
||||
if resourceGroups.enabled() {
|
||||
scanLimit = 0
|
||||
}
|
||||
tables, err := h.scanTablesForMaintenance(ctx, filerClient, workerConfig, bucketFilter, namespaceFilter, tableFilter, scanLimit)
|
||||
if err != nil {
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_error", fmt.Sprintf("error scanning tables: %v", err), nil))
|
||||
return fmt.Errorf("scan tables: %w", err)
|
||||
}
|
||||
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_complete",
|
||||
fmt.Sprintf("found %d table(s) needing maintenance", len(tables)),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
"tables_found": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(tables))}},
|
||||
}))
|
||||
|
||||
tables, hasMore := selectTablesByResourceGroup(tables, resourceGroups, maxResults)
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(tables))
|
||||
for _, t := range tables {
|
||||
proposal := h.buildMaintenanceProposal(t, filerGrpcAddress, resourceGroupKey(t, resourceGroups.GroupBy))
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: jobType,
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: jobType,
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != jobType {
|
||||
return fmt.Errorf("job type %q is not handled by iceberg maintenance handler", request.Job.JobType)
|
||||
}
|
||||
canonicalJobType := request.Job.JobType
|
||||
if canonicalJobType == "" {
|
||||
canonicalJobType = jobType
|
||||
}
|
||||
|
||||
params := request.Job.Parameters
|
||||
bucketName := readStringConfig(params, "bucket_name", "")
|
||||
namespace := readStringConfig(params, "namespace", "")
|
||||
tableName := readStringConfig(params, "table_name", "")
|
||||
tablePath := readStringConfig(params, "table_path", "")
|
||||
filerGrpcAddress := readStringConfig(params, "filer_address", "")
|
||||
|
||||
if bucketName == "" || namespace == "" || tableName == "" || filerGrpcAddress == "" {
|
||||
return fmt.Errorf("missing required parameters: bucket_name=%q, namespace=%q, table_name=%q, filer_address=%q", bucketName, namespace, tableName, filerGrpcAddress)
|
||||
}
|
||||
// Reject path traversal in bucket/namespace/table names.
|
||||
for _, name := range []string{bucketName, namespace, tableName} {
|
||||
if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") {
|
||||
return fmt.Errorf("invalid name %q: must not contain path separators or '..'", name)
|
||||
}
|
||||
}
|
||||
if tablePath == "" {
|
||||
tablePath = path.Join(namespace, tableName)
|
||||
}
|
||||
// Sanitize tablePath to prevent directory traversal.
|
||||
tablePath = path.Clean(tablePath)
|
||||
expected := path.Join(namespace, tableName)
|
||||
if tablePath != expected && !strings.HasPrefix(tablePath, expected+"/") {
|
||||
return fmt.Errorf("invalid table_path %q: must be %q or a subpath", tablePath, expected)
|
||||
}
|
||||
|
||||
workerConfig := ParseConfig(request.GetWorkerConfigValues())
|
||||
ops, opsErr := parseOperations(workerConfig.Operations)
|
||||
if opsErr != nil {
|
||||
return fmt.Errorf("invalid operations config: %w", opsErr)
|
||||
}
|
||||
if err := validateWhereOperations(workerConfig.Where, ops); err != nil {
|
||||
return fmt.Errorf("invalid where config: %w", err)
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: fmt.Sprintf("maintenance job accepted for %s/%s/%s", bucketName, namespace, tableName),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("assigned", fmt.Sprintf("maintenance job accepted for %s/%s/%s", bucketName, namespace, tableName)),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Connect to filer
|
||||
conn, err := h.dialFiler(ctx, filerGrpcAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerGrpcAddress, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||
|
||||
// Resolve once for the whole job: compaction commits new metadata as it
|
||||
// runs, and a job should not change settings halfway through. Both reads
|
||||
// fail the job rather than fall back, since without the configuration a
|
||||
// disabled operation would run anyway, and without the properties the
|
||||
// operations would rewrite the table to a size it did not ask for.
|
||||
maintenance, err := loadMaintenanceConfiguration(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read maintenance configuration for %s/%s: %w", bucketName, tablePath, err)
|
||||
}
|
||||
ops = filterDisabledOperations(ops, maintenance)
|
||||
|
||||
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read table properties for %s/%s: %w", bucketName, tablePath, err)
|
||||
}
|
||||
workerConfig = resolveTableConfig(workerConfig, state.Metadata.Properties(), maintenance)
|
||||
|
||||
var results []string
|
||||
if len(ops) == 0 {
|
||||
results = append(results, "all maintenance operations are disabled for this table")
|
||||
}
|
||||
var lastErr error
|
||||
totalOps := len(ops)
|
||||
completedOps := 0
|
||||
allMetrics := make(map[string]int64)
|
||||
opErrors := make(map[string]error, totalOps)
|
||||
|
||||
// Execute operations in canonical maintenance order as defined by
|
||||
// parseOperations.
|
||||
for _, op := range ops {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
progress := float64(completedOps) / float64(totalOps) * 100
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: op,
|
||||
Message: fmt.Sprintf("running %s", op),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity(op, fmt.Sprintf("starting %s for %s/%s/%s", op, bucketName, namespace, tableName)),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var opResult string
|
||||
var opErr error
|
||||
var opMetrics map[string]int64
|
||||
|
||||
switch op {
|
||||
case "compact":
|
||||
opResult, opMetrics, opErr = h.compactDataFiles(ctx, filerClient, bucketName, tablePath, workerConfig, func(binIdx, totalBins int) {
|
||||
binProgress := progress + float64(binIdx+1)/float64(totalBins)*(100.0/float64(totalOps))
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: binProgress,
|
||||
Stage: fmt.Sprintf("compact bin %d/%d", binIdx+1, totalBins),
|
||||
Message: fmt.Sprintf("compacting bin %d of %d", binIdx+1, totalBins),
|
||||
})
|
||||
})
|
||||
case "rewrite_position_delete_files":
|
||||
opResult, opMetrics, opErr = h.rewritePositionDeleteFiles(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "expire_snapshots":
|
||||
opResult, opMetrics, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "remove_orphans":
|
||||
opResult, opMetrics, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "rewrite_manifests":
|
||||
opResult, opMetrics, opErr = h.rewriteManifests(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
default:
|
||||
glog.Warningf("unknown maintenance operation: %s", op)
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate per-operation metrics with dot-prefixed keys
|
||||
for k, v := range opMetrics {
|
||||
allMetrics[op+"."+k] = v
|
||||
}
|
||||
|
||||
completedOps++
|
||||
opErrors[op] = opErr
|
||||
if opErr != nil {
|
||||
glog.Warningf("iceberg maintenance %s failed for %s/%s/%s: %v", op, bucketName, namespace, tableName, opErr)
|
||||
results = append(results, fmt.Sprintf("%s: error: %v", op, opErr))
|
||||
lastErr = opErr
|
||||
} else {
|
||||
results = append(results, fmt.Sprintf("%s: %s", op, opResult))
|
||||
}
|
||||
}
|
||||
|
||||
recordJobStatus(ctx, filerClient, bucketName, tablePath, buildJobStatus(opErrors, time.Now().UTC()))
|
||||
|
||||
resultSummary := strings.Join(results, "; ")
|
||||
success := lastErr == nil
|
||||
|
||||
// Build OutputValues with base table info + per-operation metrics
|
||||
outputValues := map[string]*plugin_pb.ConfigValue{
|
||||
"bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: namespace}},
|
||||
"table": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: tableName}},
|
||||
}
|
||||
for k, v := range allMetrics {
|
||||
outputValues[k] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: v}}
|
||||
}
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
Success: success,
|
||||
ErrorMessage: func() string {
|
||||
if lastErr != nil {
|
||||
return lastErr.Error()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: outputValues,
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) sendEmptyDetection(sender pluginworker.DetectionSender) error {
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: jobType,
|
||||
Proposals: []*plugin_pb.JobProposal{},
|
||||
HasMore: false,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: jobType,
|
||||
Success: true,
|
||||
TotalProposals: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// dialFiler connects to a filer at the given gRPC address. The address must
|
||||
// already be a dialable host:grpcPort: Detect resolves it from
|
||||
// ClusterContext.FilerAddresses via ToGrpcAddress and stores that resolved form
|
||||
// in the job proposal parameter, so dialFiler dials it verbatim.
|
||||
func (h *Handler) dialFiler(ctx context.Context, grpcAddress string) (*grpc.ClientConn, error) {
|
||||
opCtx, opCancel := context.WithTimeout(ctx, filerConnectTimeout)
|
||||
defer opCancel()
|
||||
|
||||
conn, err := pb.GrpcDial(opCtx, grpcAddress, false, h.grpcDialOption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := filer_pb.NewSeaweedFilerClient(conn)
|
||||
if _, err := client.Ping(opCtx, &filer_pb.PingRequest{}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// connectToFiler tries each filer gRPC address in order and returns the
|
||||
// first address whose gRPC connection and Ping request succeed.
|
||||
func (h *Handler) connectToFiler(ctx context.Context, grpcAddresses []string) (string, *grpc.ClientConn, error) {
|
||||
var lastErr error
|
||||
for _, grpcAddr := range grpcAddresses {
|
||||
conn, err := h.dialFiler(ctx, grpcAddr)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("filer %s: %w", grpcAddr, err)
|
||||
continue
|
||||
}
|
||||
return grpcAddr, conn, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no filer addresses provided")
|
||||
}
|
||||
return "", nil, lastErr
|
||||
}
|
||||
|
||||
// Ensure Handler implements JobHandler.
|
||||
var _ pluginworker.JobHandler = (*Handler)(nil)
|
||||
1669
weed/worker/tasks/iceberg/handler_test.go
Normal file
1669
weed/worker/tasks/iceberg/handler_test.go
Normal file
File diff suppressed because it is too large
Load Diff
126
weed/worker/tasks/iceberg/job_status.go
Normal file
126
weed/worker/tasks/iceberg/job_status.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// jobStatusUpdateAttempts bounds the read-modify-write retry loop for the job
|
||||
// status attribute. Asserting the whole attribute set makes a lost race more
|
||||
// likely, so allow a couple of retries before giving up.
|
||||
const jobStatusUpdateAttempts = 3
|
||||
|
||||
// buildJobStatus folds the operations this job ran into the AWS job types that
|
||||
// govern them. Operations that did not run are left out so a partial run does
|
||||
// not erase what an earlier one recorded.
|
||||
func buildJobStatus(opErrors map[string]error, ranAt time.Time) s3tables.MaintenanceJobStatus {
|
||||
jobStatus := s3tables.MaintenanceJobStatus{}
|
||||
|
||||
for op, opErr := range opErrors {
|
||||
jobType := maintenanceJobType(op)
|
||||
if jobType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value, ok := jobStatus[jobType]
|
||||
if !ok {
|
||||
timestamp := ranAt
|
||||
value = &s3tables.MaintenanceJobStatusValue{
|
||||
Status: s3tables.MaintenanceJobStatusSuccessful,
|
||||
LastRunTimestamp: ×tamp,
|
||||
}
|
||||
jobStatus[jobType] = value
|
||||
}
|
||||
|
||||
// Several operations can map to one job type; one failure fails the type.
|
||||
if opErr != nil && value.Status != s3tables.MaintenanceJobStatusFailed {
|
||||
value.Status = s3tables.MaintenanceJobStatusFailed
|
||||
value.FailureMessage = opErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return jobStatus
|
||||
}
|
||||
|
||||
// recordJobStatus merges this job's outcome into the table's stored status.
|
||||
// Status is advisory, so a lost race is logged rather than failing a job whose
|
||||
// work already committed.
|
||||
func recordJobStatus(
|
||||
ctx context.Context,
|
||||
client filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
jobStatus s3tables.MaintenanceJobStatus,
|
||||
) {
|
||||
if len(jobStatus) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
dir, name := path.Dir(tableDir), path.Base(tableDir)
|
||||
|
||||
for attempt := 0; attempt < jobStatusUpdateAttempts; attempt++ {
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
glog.V(1).Infof("iceberg maintenance: cannot record job status for %s/%s: %v", bucketName, tablePath, err)
|
||||
return
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entry := resp.Entry
|
||||
current := entry.Extended[s3tables.ExtendedKeyMaintenanceStatus]
|
||||
// Assert every attribute, not just our own: UpdateEntry rewrites the
|
||||
// whole entry, so a narrow assertion would let this write revert a
|
||||
// maintenance configuration an operator just disabled.
|
||||
expected := s3tables.SnapshotExtended(entry.Extended, s3tables.ExtendedKeyMaintenanceStatus)
|
||||
|
||||
merged := s3tables.MaintenanceJobStatus{}
|
||||
if len(current) > 0 {
|
||||
if err := json.Unmarshal(current, &merged); err != nil {
|
||||
glog.V(1).Infof("iceberg maintenance: replacing unreadable job status on %s/%s: %v", bucketName, tablePath, err)
|
||||
merged = s3tables.MaintenanceJobStatus{}
|
||||
}
|
||||
}
|
||||
for jobType, value := range jobStatus {
|
||||
merged[jobType] = value
|
||||
}
|
||||
|
||||
data, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
glog.V(1).Infof("iceberg maintenance: cannot marshal job status for %s/%s: %v", bucketName, tablePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry.Extended == nil {
|
||||
entry.Extended = make(map[string][]byte)
|
||||
}
|
||||
entry.Extended[s3tables.ExtendedKeyMaintenanceStatus] = data
|
||||
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: entry,
|
||||
ExpectedExtended: expected,
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if status.Code(err) != codes.FailedPrecondition {
|
||||
glog.V(1).Infof("iceberg maintenance: cannot record job status for %s/%s: %v", bucketName, tablePath, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(1).Infof("iceberg maintenance: gave up recording job status for %s/%s after %d attempts", bucketName, tablePath, jobStatusUpdateAttempts)
|
||||
}
|
||||
734
weed/worker/tasks/iceberg/operations.go
Normal file
734
weed/worker/tasks/iceberg/operations.go
Normal file
@@ -0,0 +1,734 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"path"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// errStalePlan is returned by a commit mutation when the table head has
|
||||
// advanced since planning. The caller should not retry the same plan.
|
||||
var errStalePlan = errors.New("stale plan: table head changed since planning")
|
||||
|
||||
// errMetadataVersionConflict is returned when the xattr update detects a
|
||||
// concurrent metadata version change (compare-and-swap failure).
|
||||
var errMetadataVersionConflict = errors.New("metadata version conflict")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation: Expire Snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// expireSnapshots removes old snapshots from the table metadata and cleans up
|
||||
// their manifest list files.
|
||||
func (h *Handler) expireSnapshots(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
meta, dataPath := state.Metadata, state.DataPath
|
||||
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
return "no snapshots", nil, nil
|
||||
}
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
var currentSnapID int64
|
||||
if currentSnap != nil {
|
||||
currentSnapID = currentSnap.SnapshotID
|
||||
}
|
||||
|
||||
toExpire := snapshotsToExpire(meta, config, time.Now().UnixMilli())
|
||||
if len(toExpire) == 0 {
|
||||
return "no snapshots expired", nil, nil
|
||||
}
|
||||
|
||||
// Split snapshots into expired and kept sets
|
||||
expireSet := make(map[int64]struct{}, len(toExpire))
|
||||
for _, id := range toExpire {
|
||||
expireSet[id] = struct{}{}
|
||||
}
|
||||
var expiredSnaps, keptSnaps []table.Snapshot
|
||||
for _, snap := range snapshots {
|
||||
if _, ok := expireSet[snap.SnapshotID]; ok {
|
||||
expiredSnaps = append(expiredSnaps, snap)
|
||||
} else {
|
||||
keptSnaps = append(keptSnaps, snap)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all files referenced by each set before modifying metadata.
|
||||
// This lets us determine which files become unreferenced.
|
||||
expiredFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, dataPath, expiredSnaps)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("collect expired snapshot files: %w", err)
|
||||
}
|
||||
keptFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, dataPath, keptSnaps)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("collect kept snapshot files: %w", err)
|
||||
}
|
||||
|
||||
// Resolve kept file paths for consistent comparison
|
||||
keptFilerPaths := make(map[string]struct{}, len(keptFiles))
|
||||
for f := range keptFiles {
|
||||
if filerPath, err := icebergFilerPath(bucketName, dataPath, f); err == nil {
|
||||
keptFilerPaths[filerPath] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Use MetadataBuilder to remove snapshots and create new metadata
|
||||
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, state.MetadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
// Guard: verify table head hasn't changed since we planned
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if (cs == nil) != (currentSnapID == 0) || (cs != nil && cs.SnapshotID != currentSnapID) {
|
||||
return errStalePlan
|
||||
}
|
||||
// A tag or branch created since planning can pin a snapshot this plan
|
||||
// expires without moving the head, and RemoveSnapshots would drop that
|
||||
// ref along with it. Re-plan instead.
|
||||
nowProtected := protectedSnapshots(currentMeta, time.Now().UnixMilli())
|
||||
for _, id := range toExpire {
|
||||
if _, pinned := nowProtected[id]; pinned {
|
||||
return errStalePlan
|
||||
}
|
||||
}
|
||||
return builder.RemoveSnapshots(toExpire, false)
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("commit snapshot expiration: %w", err)
|
||||
}
|
||||
|
||||
// Delete files exclusively referenced by expired snapshots (best-effort)
|
||||
deletedCount := 0
|
||||
for filePath := range expiredFiles {
|
||||
resolved, err := icebergFilerPath(bucketName, dataPath, filePath)
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: cannot resolve expired file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
if _, stillReferenced := keptFilerPaths[resolved]; stillReferenced {
|
||||
continue
|
||||
}
|
||||
if delErr := deleteFilerFile(ctx, filerClient, path.Dir(resolved), path.Base(resolved)); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete unreferenced file %s: %v", filePath, delErr)
|
||||
} else {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
metrics := map[string]int64{
|
||||
MetricSnapshotsExpired: int64(len(toExpire)),
|
||||
MetricFilesDeleted: int64(deletedCount),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("expired %d snapshot(s), deleted %d unreferenced file(s)", len(toExpire), deletedCount), metrics, nil
|
||||
}
|
||||
|
||||
// snapshotsToExpire returns the snapshot IDs that fall outside the configured
|
||||
// retention. Detection and execution both call it so a table is only proposed
|
||||
// for expiry when the run would actually remove something.
|
||||
func snapshotsToExpire(meta table.Metadata, config Config, nowMs int64) []int64 {
|
||||
var currentSnapID int64
|
||||
if currentSnap := meta.CurrentSnapshot(); currentSnap != nil {
|
||||
currentSnapID = currentSnap.SnapshotID
|
||||
}
|
||||
retentionMs := config.SnapshotRetentionMs
|
||||
protected := protectedSnapshots(meta, nowMs)
|
||||
|
||||
// Sort snapshots by timestamp descending (most recent first) so that
|
||||
// the keep-count logic always preserves the newest snapshots.
|
||||
sorted := make([]table.Snapshot, len(meta.Snapshots()))
|
||||
copy(sorted, meta.Snapshots())
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].TimestampMs > sorted[j].TimestampMs
|
||||
})
|
||||
|
||||
// Walk from newest to oldest. The current snapshot is always kept.
|
||||
// Among the remaining, keep up to MaxSnapshotsToKeep-1 (since current
|
||||
// counts toward the quota). Expire the rest only if they exceed the
|
||||
// retention window; snapshots within the window are kept regardless.
|
||||
var toExpire []int64
|
||||
var kept int64
|
||||
for _, snap := range sorted {
|
||||
if snap.SnapshotID == currentSnapID {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if _, isProtected := protected[snap.SnapshotID]; isProtected {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if kept < config.MaxSnapshotsToKeep {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if nowMs-snap.TimestampMs > retentionMs {
|
||||
toExpire = append(toExpire, snap.SnapshotID)
|
||||
} else {
|
||||
kept++
|
||||
}
|
||||
}
|
||||
return toExpire
|
||||
}
|
||||
|
||||
// protectedSnapshots returns the snapshots that named refs hold in place.
|
||||
// A branch head or a tag pins its snapshot no matter how old it is, and a
|
||||
// branch may carry its own retention overrides for the ancestors behind it.
|
||||
// iceberg-go's RemoveSnapshots drops any ref whose snapshot is gone without
|
||||
// complaint, so expiring one of these would silently delete the tag and then
|
||||
// the files it pointed at.
|
||||
func protectedSnapshots(meta table.Metadata, nowMs int64) map[int64]struct{} {
|
||||
protected := make(map[int64]struct{})
|
||||
byID := make(map[int64]table.Snapshot)
|
||||
for _, snap := range meta.Snapshots() {
|
||||
byID[snap.SnapshotID] = snap
|
||||
}
|
||||
|
||||
for _, ref := range meta.Refs() {
|
||||
protected[ref.SnapshotID] = struct{}{}
|
||||
if ref.SnapshotRefType != table.BranchRef {
|
||||
continue
|
||||
}
|
||||
// Without overrides the branch keeps only its head here; the ancestors
|
||||
// behind it stay under the worker's own retention config.
|
||||
if ref.MinSnapshotsToKeep == nil && ref.MaxSnapshotAgeMs == nil {
|
||||
continue
|
||||
}
|
||||
minToKeep := 1
|
||||
if ref.MinSnapshotsToKeep != nil {
|
||||
minToKeep = *ref.MinSnapshotsToKeep
|
||||
}
|
||||
seen := make(map[int64]struct{})
|
||||
kept := 0
|
||||
for id := ref.SnapshotID; ; {
|
||||
if _, looped := seen[id]; looped {
|
||||
break
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
snap, ok := byID[id]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
withinAge := ref.MaxSnapshotAgeMs != nil && nowMs-snap.TimestampMs <= *ref.MaxSnapshotAgeMs
|
||||
if kept >= minToKeep && !withinAge {
|
||||
break
|
||||
}
|
||||
protected[snap.SnapshotID] = struct{}{}
|
||||
kept++
|
||||
if snap.ParentSnapshotID == nil {
|
||||
break
|
||||
}
|
||||
id = *snap.ParentSnapshotID
|
||||
}
|
||||
}
|
||||
return protected
|
||||
}
|
||||
|
||||
// collectSnapshotFiles returns all file paths (manifest lists, manifest files,
|
||||
// data files) referenced by the given snapshots. It returns an error if any
|
||||
// manifest list or manifest cannot be read/parsed, to prevent delete decisions
|
||||
// based on incomplete reference data.
|
||||
func collectSnapshotFiles(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
snapshots []table.Snapshot,
|
||||
) (map[string]struct{}, error) {
|
||||
files := make(map[string]struct{})
|
||||
for _, snap := range snapshots {
|
||||
if snap.ManifestList == "" {
|
||||
continue
|
||||
}
|
||||
files[snap.ManifestList] = struct{}{}
|
||||
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, snap.ManifestList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest list %s: %w", snap.ManifestList, err)
|
||||
}
|
||||
manifests, err := s3tables.ReadManifestList(manifestListData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest list %s: %w", snap.ManifestList, err)
|
||||
}
|
||||
|
||||
for _, mf := range manifests {
|
||||
files[mf.FilePath()] = struct{}{}
|
||||
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
files[entry.DataFile().FilePath()] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation: Remove Orphans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// removeOrphans finds and deletes unreferenced files from the table's
|
||||
// metadata/ and data/ directories.
|
||||
func (h *Handler) removeOrphans(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, state.DataPath, state.Metadata, state.MetadataFileName, config.OrphanOlderThanHours)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("collect orphan candidates: %w", err)
|
||||
}
|
||||
|
||||
orphanCount := 0
|
||||
for _, candidate := range orphanCandidates {
|
||||
if delErr := deleteFilerFile(ctx, filerClient, candidate.Dir, candidate.Entry.Name); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete orphan %s/%s: %v", candidate.Dir, candidate.Entry.Name, delErr)
|
||||
} else {
|
||||
orphanCount++
|
||||
}
|
||||
}
|
||||
|
||||
metrics := map[string]int64{
|
||||
MetricOrphansRemoved: int64(orphanCount),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("removed %d orphan file(s)", orphanCount), metrics, nil
|
||||
}
|
||||
|
||||
func collectOrphanCandidates(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
meta table.Metadata,
|
||||
metadataFileName string,
|
||||
orphanOlderThanHours int64,
|
||||
) ([]filerFileEntry, error) {
|
||||
referencedFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, dataPath, meta.Snapshots())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect referenced files: %w", err)
|
||||
}
|
||||
|
||||
referencedFiles[path.Join("metadata", metadataFileName)] = struct{}{}
|
||||
for mle := range meta.PreviousFiles() {
|
||||
referencedFiles[mle.MetadataFile] = struct{}{}
|
||||
}
|
||||
|
||||
referencedFilerPaths := make(map[string]struct{}, len(referencedFiles))
|
||||
for ref := range referencedFiles {
|
||||
if filerPath, err := icebergFilerPath(bucketName, dataPath, ref); err == nil {
|
||||
referencedFilerPaths[filerPath] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
tableBasePath := path.Join(s3tables.TablesPath, bucketName, dataPath)
|
||||
safetyThreshold := time.Now().Add(-time.Duration(orphanOlderThanHours) * time.Hour)
|
||||
var candidates []filerFileEntry
|
||||
|
||||
for _, subdir := range []string{"metadata", "data"} {
|
||||
dirPath := path.Join(tableBasePath, subdir)
|
||||
fileEntries, err := walkFilerEntries(ctx, filerClient, dirPath)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", dirPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, fe := range fileEntries {
|
||||
entry := fe.Entry
|
||||
if _, isReferenced := referencedFilerPaths[path.Join(fe.Dir, entry.Name)]; isReferenced {
|
||||
continue
|
||||
}
|
||||
if entry.Attributes == nil {
|
||||
continue
|
||||
}
|
||||
if time.Unix(entry.Attributes.Mtime, 0).After(safetyThreshold) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, fe)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation: Rewrite Manifests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// rewriteManifests merges small manifests into fewer, larger ones.
|
||||
func (h *Handler) rewriteManifests(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
meta, dataPath := state.Metadata, state.DataPath
|
||||
predicate, err := parsePartitionPredicate(config.Where, meta)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return "no current snapshot", nil, nil
|
||||
}
|
||||
|
||||
// Read manifest list
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
|
||||
manifests, err := s3tables.ReadManifestList(manifestListData)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
|
||||
// Separate data manifests from delete manifests. Only data manifests
|
||||
// are candidates for rewriting; delete manifests are carried forward.
|
||||
var dataManifests []iceberg.ManifestFile
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() == iceberg.ManifestContentData {
|
||||
dataManifests = append(dataManifests, mf)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all entries from data manifests, grouped by partition spec ID
|
||||
// so we write one merged manifest per spec (required for spec-evolved tables).
|
||||
type specEntries struct {
|
||||
specID int32
|
||||
spec iceberg.PartitionSpec
|
||||
entries []iceberg.ManifestEntry
|
||||
}
|
||||
specMap := make(map[int32]*specEntries)
|
||||
|
||||
// Build a lookup from spec ID to PartitionSpec
|
||||
specByID := specByID(meta)
|
||||
schema := meta.CurrentSchema()
|
||||
var carriedDataManifests []iceberg.ManifestFile
|
||||
var manifestsRewritten int64
|
||||
|
||||
for _, mf := range dataManifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, dataPath, mf.FilePath())
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := s3tables.ReadManifest(mf, manifestData, true, specByID, schema)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
sid := mf.PartitionSpecID()
|
||||
spec, found := specByID[int(sid)]
|
||||
if !found {
|
||||
return "", nil, fmt.Errorf("partition spec %d not found in table metadata", sid)
|
||||
}
|
||||
|
||||
if predicate != nil {
|
||||
allMatch := len(entries) > 0
|
||||
for _, entry := range entries {
|
||||
match, err := predicate.Matches(spec, entry.DataFile().Partition())
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !match {
|
||||
allMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allMatch {
|
||||
carriedDataManifests = append(carriedDataManifests, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
se, ok := specMap[sid]
|
||||
if !ok {
|
||||
se = &specEntries{specID: sid, spec: spec}
|
||||
specMap[sid] = se
|
||||
}
|
||||
se.entries = append(se.entries, entries...)
|
||||
manifestsRewritten++
|
||||
}
|
||||
|
||||
if manifestsRewritten < config.MinManifestsToRewrite {
|
||||
return fmt.Sprintf("only %d data manifests, below threshold of %d", manifestsRewritten, config.MinManifestsToRewrite), nil, nil
|
||||
}
|
||||
|
||||
if len(specMap) == 0 {
|
||||
return "no data entries to rewrite", nil, nil
|
||||
}
|
||||
|
||||
version := meta.Version()
|
||||
snapshotID := currentSnap.SnapshotID
|
||||
newSnapshotID := time.Now().UnixMilli()
|
||||
newSeqNum := currentSnap.SequenceNumber + 1
|
||||
artifactSuffix := compactRandomSuffix()
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "metadata")
|
||||
|
||||
// Track written artifacts so we can clean them up if the commit fails.
|
||||
type artifact struct {
|
||||
dir, fileName string
|
||||
}
|
||||
var writtenArtifacts []artifact
|
||||
committed := false
|
||||
|
||||
defer func() {
|
||||
if committed || len(writtenArtifacts) == 0 {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, a := range writtenArtifacts {
|
||||
if err := deleteFilerFile(cleanupCtx, filerClient, a.dir, a.fileName); err != nil {
|
||||
glog.Warningf("iceberg rewrite-manifests: failed to clean up artifact %s/%s: %v", a.dir, a.fileName, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Write one merged manifest per partition spec
|
||||
var newManifests []iceberg.ManifestFile
|
||||
newManifests = append(newManifests, carriedDataManifests...)
|
||||
totalEntries := 0
|
||||
for _, se := range specMap {
|
||||
totalEntries += len(se.entries)
|
||||
manifestFileName := fmt.Sprintf("merged-%d-%s-spec%d.avro", newSnapshotID, artifactSuffix, se.specID)
|
||||
manifestPath := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestFileName)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
mergedManifest, err := iceberg.WriteManifest(
|
||||
manifestPath,
|
||||
&manifestBuf,
|
||||
version,
|
||||
se.spec,
|
||||
schema,
|
||||
newSnapshotID,
|
||||
se.entries,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("write merged manifest for spec %d: %w", se.specID, err)
|
||||
}
|
||||
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestFileName, manifestBuf.Bytes()); err != nil {
|
||||
return "", nil, fmt.Errorf("save merged manifest for spec %d: %w", se.specID, err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestFileName})
|
||||
newManifests = append(newManifests, mergedManifest)
|
||||
}
|
||||
|
||||
// Include any delete manifests that were not rewritten
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
newManifests = append(newManifests, mf)
|
||||
}
|
||||
}
|
||||
|
||||
var manifestListBuf bytes.Buffer
|
||||
err = iceberg.WriteManifestList(version, &manifestListBuf, newSnapshotID, &snapshotID, &newSeqNum, 0, newManifests)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("write manifest list: %w", err)
|
||||
}
|
||||
|
||||
// Save new manifest list
|
||||
manifestListFileName := fmt.Sprintf("snap-%d-%s.avro", newSnapshotID, artifactSuffix)
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListFileName, manifestListBuf.Bytes()); err != nil {
|
||||
return "", nil, fmt.Errorf("save manifest list: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListFileName})
|
||||
|
||||
// Create new snapshot with the rewritten manifest list
|
||||
manifestListLocation := absoluteIcebergPath(bucketName, dataPath, "metadata", manifestListFileName)
|
||||
|
||||
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, state.MetadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
// Guard: verify table head hasn't advanced since we planned.
|
||||
// The merged manifest and manifest list were built against snapshotID;
|
||||
// if the head moved, they reference stale state.
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if cs == nil || cs.SnapshotID != snapshotID {
|
||||
return errStalePlan
|
||||
}
|
||||
|
||||
newSnapshot := &table.Snapshot{
|
||||
SnapshotID: newSnapshotID,
|
||||
ParentSnapshotID: &snapshotID,
|
||||
SequenceNumber: cs.SequenceNumber + 1,
|
||||
TimestampMs: time.Now().UnixMilli(),
|
||||
ManifestList: manifestListLocation,
|
||||
// Merging manifests changes no file, so the totals carry over.
|
||||
Summary: snapshotSummary{}.build(table.OpReplace, cs, map[string]string{"maintenance": "rewrite_manifests"}),
|
||||
SchemaID: func() *int {
|
||||
id := schema.ID
|
||||
return &id
|
||||
}(),
|
||||
}
|
||||
if err := builder.AddSnapshot(newSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return builder.SetSnapshotRef(
|
||||
table.MainBranch,
|
||||
newSnapshotID,
|
||||
table.BranchRef,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("commit manifest rewrite: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
metrics := map[string]int64{
|
||||
MetricManifestsRewritten: manifestsRewritten,
|
||||
MetricEntriesTotal: int64(totalEntries),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", manifestsRewritten, len(specMap), totalEntries), metrics, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit Protocol with Retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// commitWithRetry implements optimistic concurrency for metadata updates.
|
||||
// It reads the current metadata, applies the mutation, writes a new metadata
|
||||
// file, and updates the table entry. On version conflict, it retries.
|
||||
func (h *Handler) commitWithRetry(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath, currentMetadataFileName string,
|
||||
config Config,
|
||||
mutate func(currentMeta table.Metadata, builder *table.MetadataBuilder) error,
|
||||
) error {
|
||||
maxRetries := config.MaxCommitRetries
|
||||
if maxRetries <= 0 || maxRetries > 20 {
|
||||
maxRetries = defaultMaxCommitRetries
|
||||
}
|
||||
|
||||
for attempt := int64(0); attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(50*(1<<(attempt-1))) * time.Millisecond // exponential: 50ms, 100ms, 200ms, ...
|
||||
const maxBackoff = 5 * time.Second
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
jitter := time.Duration(rand.Int64N(int64(backoff) / 5)) // 0–20% of backoff
|
||||
timer := time.NewTimer(backoff + jitter)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Load current metadata
|
||||
state, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load metadata (attempt %d): %w", attempt, err)
|
||||
}
|
||||
meta, metaFileName, dataPath := state.Metadata, state.MetadataFileName, state.DataPath
|
||||
|
||||
// Build new metadata — pass the current metadata file location so the
|
||||
// metadata log correctly records where the previous version lives.
|
||||
currentMetaFilePath := absoluteIcebergPath(bucketName, dataPath, "metadata", metaFileName)
|
||||
builder, err := table.MetadataBuilderFromBase(meta, currentMetaFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create metadata builder (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Apply the mutation
|
||||
if err := mutate(meta, builder); err != nil {
|
||||
return fmt.Errorf("apply mutation (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
if !builder.HasChanges() {
|
||||
return nil // nothing to commit
|
||||
}
|
||||
|
||||
newMeta, err := builder.Build()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build metadata (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Serialize
|
||||
metadataBytes, err := json.Marshal(newMeta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Determine new metadata file name. Include a timestamp suffix so
|
||||
// concurrent writers stage to distinct files instead of clobbering.
|
||||
currentVersion := extractMetadataVersion(metaFileName)
|
||||
newVersion := currentVersion + 1
|
||||
newMetadataFileName := fmt.Sprintf("v%d-%d.metadata.json", newVersion, time.Now().UnixNano())
|
||||
|
||||
// Save new metadata file
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, dataPath, "metadata")
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, newMetadataFileName, metadataBytes); err != nil {
|
||||
return fmt.Errorf("save metadata file (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Update the table entry's xattr with new metadata (CAS on version)
|
||||
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
newMetadataLocation := absoluteIcebergPath(bucketName, dataPath, "metadata", newMetadataFileName)
|
||||
err = updateTableMetadataXattr(ctx, filerClient, tableDir, currentVersion, metadataBytes, newMetadataLocation)
|
||||
if err != nil {
|
||||
// Use a detached context for cleanup so staged files are removed
|
||||
// even if the original context was canceled.
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if !errors.Is(err, errMetadataVersionConflict) {
|
||||
// Non-conflict error (permissions, transport, etc.): fail immediately.
|
||||
_ = deleteFilerFile(cleanupCtx, filerClient, metaDir, newMetadataFileName)
|
||||
cleanupCancel()
|
||||
return fmt.Errorf("update table xattr (attempt %d): %w", attempt, err)
|
||||
}
|
||||
// Version conflict: clean up the new metadata file and retry
|
||||
_ = deleteFilerFile(cleanupCtx, filerClient, metaDir, newMetadataFileName)
|
||||
cleanupCancel()
|
||||
if attempt < maxRetries-1 {
|
||||
glog.V(1).Infof("iceberg maintenance: version conflict on %s/%s, retrying (attempt %d)", bucketName, tablePath, attempt)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("update table xattr (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("exceeded max commit retries (%d)", maxRetries)
|
||||
}
|
||||
152
weed/worker/tasks/iceberg/operations_test.go
Normal file
152
weed/worker/tasks/iceberg/operations_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
||||
)
|
||||
|
||||
// Merging manifests a foreign producer wrote used to fail for good on a
|
||||
// day-partitioned table, whichever way round the producer spelled the
|
||||
// partition union: nothing between reading an entry and writing it looks at
|
||||
// its partition, so the time.Time the Avro decoder produced is still there
|
||||
// when the manifest writer, which has no date logical type for a day
|
||||
// partition, tries to encode it.
|
||||
func TestRewriteManifestsNormalizesForeignDayPartitions(t *testing.T) {
|
||||
for _, valueFirst := range []bool{true, false} {
|
||||
name := "null-first partition union"
|
||||
if valueFirst {
|
||||
name = "value-first partition union"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rewriteForeignDayPartitions(t, valueFirst)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteForeignDayPartitions(t *testing.T, valueFirst bool) {
|
||||
t.Helper()
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
schema := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Name: "event_time", Type: iceberg.PrimitiveTypes.Timestamp, Required: true},
|
||||
)
|
||||
spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
|
||||
SourceIDs: []int{1}, FieldID: 1000, Name: "event_time_day", Transform: iceberg.DayTransform{},
|
||||
})
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
Schema: schema,
|
||||
Spec: &spec,
|
||||
Snapshots: []table.Snapshot{{
|
||||
SnapshotID: 1,
|
||||
TimestampMs: time.Now().UnixMilli(),
|
||||
ManifestList: "metadata/snap-1.avro",
|
||||
}},
|
||||
}
|
||||
meta := populateTable(t, fs, setup)
|
||||
|
||||
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
|
||||
wantDays := make(map[string]iceberg.Date)
|
||||
var manifests []iceberg.ManifestFile
|
||||
for i := 0; i < 3; i++ {
|
||||
day := iceberg.Date(20737 + i)
|
||||
filePath := setup.fileRef("data", fmt.Sprintf("foreign-%d.parquet", i))
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, filePath, iceberg.ParquetFile,
|
||||
map[int]any{1000: day}, nil, nil, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("build data file %d: %v", i, err)
|
||||
}
|
||||
snapshotID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapshotID, nil, nil, dfBuilder.Build())
|
||||
|
||||
manifestName := fmt.Sprintf("foreign-manifest-%d.avro", i)
|
||||
foreignBytes, manifest := s3tablestest.ForeignPartitionManifest(t, schema, spec, entry,
|
||||
setup.fileRef("metadata", manifestName), "date", time.Unix(int64(day)*24*60*60, 0).UTC(), valueFirst)
|
||||
fs.putEntry(metaDir, manifestName, &filer_pb.Entry{
|
||||
Name: manifestName, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}, Content: foreignBytes,
|
||||
})
|
||||
manifests = append(manifests, manifest)
|
||||
wantDays[filePath] = day
|
||||
}
|
||||
|
||||
var manifestList bytes.Buffer
|
||||
seqNum := int64(1)
|
||||
if err := iceberg.WriteManifestList(meta.Version(), &manifestList, 1, nil, &seqNum, 0, manifests); err != nil {
|
||||
t.Fatalf("write manifest list: %v", err)
|
||||
}
|
||||
fs.putEntry(metaDir, "snap-1.avro", &filer_pb.Entry{
|
||||
Name: "snap-1.avro", Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}, Content: manifestList.Bytes(),
|
||||
})
|
||||
|
||||
result, _, err := NewHandler(nil).rewriteManifests(context.Background(), client, setup.BucketName, setup.tablePath(), Config{
|
||||
MinManifestsToRewrite: 3,
|
||||
MaxCommitRetries: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rewriteManifests failed for foreign day partitions: %v", err)
|
||||
}
|
||||
if result != "rewrote 3 manifests into 1 (3 entries)" {
|
||||
t.Fatalf("rewriteManifests result = %q, want 3 manifests merged", result)
|
||||
}
|
||||
|
||||
// The merged manifest has to carry the days the foreign manifests held.
|
||||
// iceberg-go writes a day partition as a bare Avro int, without the date
|
||||
// logical type, but now converts day-transform partitions to iceberg.Date
|
||||
// on read (applyDayTransformDates), so they read back as iceberg.Date.
|
||||
got := make(map[string]any)
|
||||
for _, mf := range currentManifests(t, client, setup) {
|
||||
manifestData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), mf.FilePath())
|
||||
if err != nil {
|
||||
t.Fatalf("load merged manifest %s: %v", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
t.Fatalf("parse merged manifest %s: %v", mf.FilePath(), err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
got[entry.DataFile().FilePath()] = entry.DataFile().Partition()[1000]
|
||||
}
|
||||
}
|
||||
if len(got) != len(wantDays) {
|
||||
t.Fatalf("merged manifests hold %d entries, want %d", len(got), len(wantDays))
|
||||
}
|
||||
for filePath, day := range wantDays {
|
||||
if got[filePath] != day {
|
||||
t.Errorf("%s partition = %#v (%T), want %d", filePath, got[filePath], got[filePath], day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// currentManifests reads the manifests of the table's current snapshot.
|
||||
func currentManifests(t *testing.T, client filer_pb.SeaweedFilerClient, setup tableSetup) []iceberg.ManifestFile {
|
||||
t.Helper()
|
||||
state, err := loadCurrentMetadata(context.Background(), client, setup.BucketName, setup.tablePath())
|
||||
if err != nil {
|
||||
t.Fatalf("reload metadata: %v", err)
|
||||
}
|
||||
snapshot := state.Metadata.CurrentSnapshot()
|
||||
if snapshot == nil {
|
||||
t.Fatal("table has no current snapshot")
|
||||
}
|
||||
manifestListData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), snapshot.ManifestList)
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest list: %v", err)
|
||||
}
|
||||
manifests, err := s3tables.ReadManifestList(manifestListData)
|
||||
if err != nil {
|
||||
t.Fatalf("parse manifest list: %v", err)
|
||||
}
|
||||
return manifests
|
||||
}
|
||||
307
weed/worker/tasks/iceberg/planning_index.go
Normal file
307
weed/worker/tasks/iceberg/planning_index.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type planningIndex struct {
|
||||
SnapshotID int64 `json:"snapshotId"`
|
||||
ManifestList string `json:"manifestList,omitempty"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
DataManifestCount int64 `json:"dataManifestCount,omitempty"`
|
||||
Compaction *planningIndexCompaction `json:"compaction,omitempty"`
|
||||
RewriteManifests *planningIndexRewriteManifests `json:"rewriteManifests,omitempty"`
|
||||
}
|
||||
|
||||
type planningIndexCompaction struct {
|
||||
ConfigHash string `json:"configHash"`
|
||||
Eligible bool `json:"eligible"`
|
||||
}
|
||||
|
||||
type planningIndexRewriteManifests struct {
|
||||
Threshold int64 `json:"threshold"`
|
||||
Eligible bool `json:"eligible"`
|
||||
}
|
||||
|
||||
type tableMetadataEnvelope struct {
|
||||
// Format is the catalog's own record of what the table is. A native Lance
|
||||
// table has no Iceberg metadata at all, so without this the parse below
|
||||
// fails and the table is skipped as if it were corrupt.
|
||||
Format string `json:"format"`
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
MetadataLocation string `json:"metadataLocation,omitempty"`
|
||||
Metadata *struct {
|
||||
FullMetadata json.RawMessage `json:"fullMetadata,omitempty"`
|
||||
} `json:"metadata,omitempty"`
|
||||
PlanningIndex json.RawMessage `json:"planningIndex,omitempty"`
|
||||
}
|
||||
|
||||
// tableState is the catalog's view of one table: its Iceberg metadata, the
|
||||
// metadata file backing it, where its files live, and the cached planning index.
|
||||
type tableState struct {
|
||||
Metadata table.Metadata
|
||||
MetadataFileName string
|
||||
// DataPath is the bucket-relative directory holding metadata/ and data/.
|
||||
DataPath string
|
||||
PlanningIndex *planningIndex
|
||||
}
|
||||
|
||||
// tableTypeProperty is the Hive/Glue-style format marker. Catalogs that have no
|
||||
// native concept of a non-Iceberg table register one as an Iceberg table with a
|
||||
// placeholder schema and set this property instead; the Lance namespace's
|
||||
// Iceberg REST adapter writes table_type=lance.
|
||||
const tableTypeProperty = "table_type"
|
||||
|
||||
// errForeignFormat means the catalog entry belongs to a format this worker does
|
||||
// not maintain. It is a normal outcome of scanning a mixed catalog, not damage.
|
||||
var errForeignFormat = errors.New("not an iceberg table")
|
||||
|
||||
// isIcebergTableEntry reports whether a catalog entry is an Iceberg table this
|
||||
// worker may rewrite. Views share the entry shape, and a foreign format
|
||||
// registered through the catalog shares the location but not the file layout -
|
||||
// a Lance dataset keeps its fragments under data/, where every one of them is
|
||||
// unreferenced by the Iceberg metadata and so looks like an orphan.
|
||||
func isIcebergTableEntry(extended map[string][]byte, meta table.Metadata) bool {
|
||||
if s3tables.EntryType(extended) != s3tables.EntryTypeTable {
|
||||
return false
|
||||
}
|
||||
tableType, ok := meta.Properties()[tableTypeProperty]
|
||||
return !ok || strings.EqualFold(tableType, "iceberg")
|
||||
}
|
||||
|
||||
func parseTableMetadataEnvelope(metadataBytes []byte, bucketName, tablePath string) (*tableState, error) {
|
||||
var envelope tableMetadataEnvelope
|
||||
if err := json.Unmarshal(metadataBytes, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("parse metadata xattr: %w", err)
|
||||
}
|
||||
if envelope.Format != "" && envelope.Format != s3tables.FormatIceberg {
|
||||
return nil, fmt.Errorf("%w: %s", errForeignFormat, envelope.Format)
|
||||
}
|
||||
if envelope.Metadata == nil || len(envelope.Metadata.FullMetadata) == 0 {
|
||||
return nil, fmt.Errorf("no fullMetadata in table xattr")
|
||||
}
|
||||
|
||||
meta, err := table.ParseMetadataBytes(envelope.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse iceberg metadata: %w", err)
|
||||
}
|
||||
|
||||
var index *planningIndex
|
||||
if len(envelope.PlanningIndex) > 0 {
|
||||
if err := json.Unmarshal(envelope.PlanningIndex, &index); err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: ignoring invalid planning index cache: %v", err)
|
||||
index = nil
|
||||
}
|
||||
}
|
||||
|
||||
metadataFileName := metadataFileNameFromLocation(envelope.MetadataLocation)
|
||||
if metadataFileName == "" {
|
||||
metadataFileName = fmt.Sprintf("v%d.metadata.json", envelope.MetadataVersion)
|
||||
}
|
||||
return &tableState{
|
||||
Metadata: meta,
|
||||
MetadataFileName: metadataFileName,
|
||||
DataPath: tableDataPath(bucketName, tablePath, envelope.MetadataLocation),
|
||||
PlanningIndex: index,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tableDataPath resolves the bucket-relative directory holding a table's files.
|
||||
// The catalog records the table's real location in the metadata location, which
|
||||
// is the catalog path only for tables the catalog placed there itself: a client
|
||||
// creating a table through the REST catalog can be handed a location elsewhere
|
||||
// in the bucket, e.g. "ns/table-<uuid>" when the catalog path was occupied.
|
||||
// Locations outside the table's own bucket fall back to the catalog path.
|
||||
func tableDataPath(bucketName, tablePath, metadataLocation string) string {
|
||||
dataDir := s3tables.TableDataDirFromMetadataLocation(metadataLocation)
|
||||
if rel, ok := cutPathPrefix(dataDir, path.Join(s3tables.TablesPath, bucketName)); ok && rel != "" {
|
||||
return rel
|
||||
}
|
||||
return tablePath
|
||||
}
|
||||
|
||||
func (idx *planningIndex) matchesSnapshot(meta table.Metadata) bool {
|
||||
if idx == nil {
|
||||
return false
|
||||
}
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return false
|
||||
}
|
||||
return idx.SnapshotID == currentSnap.SnapshotID && idx.ManifestList == currentSnap.ManifestList
|
||||
}
|
||||
|
||||
func (idx *planningIndex) compactionEligible(config Config) (bool, bool) {
|
||||
if idx == nil || idx.Compaction == nil {
|
||||
return false, false
|
||||
}
|
||||
if idx.Compaction.ConfigHash != compactionPlanningConfigHash(config) {
|
||||
return false, false
|
||||
}
|
||||
return idx.Compaction.Eligible, true
|
||||
}
|
||||
|
||||
func (idx *planningIndex) rewriteManifestsEligible(config Config) (bool, bool) {
|
||||
if idx == nil || idx.RewriteManifests == nil {
|
||||
return false, false
|
||||
}
|
||||
if idx.RewriteManifests.Threshold != config.MinManifestsToRewrite {
|
||||
return false, false
|
||||
}
|
||||
return idx.RewriteManifests.Eligible, true
|
||||
}
|
||||
|
||||
func compactionPlanningConfigHash(config Config) string {
|
||||
return fmt.Sprintf("target=%d|min=%d|strategy=%s|sortcap=%d",
|
||||
config.TargetFileSizeBytes, config.MinInputFiles,
|
||||
config.RewriteStrategy, config.SortMaxInputBytes)
|
||||
}
|
||||
|
||||
func operationRequested(ops []string, wanted string) bool {
|
||||
for _, op := range ops {
|
||||
if op == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergePlanningIndexSections(index, existing *planningIndex) *planningIndex {
|
||||
if index == nil || existing == nil {
|
||||
return index
|
||||
}
|
||||
if index.SnapshotID != existing.SnapshotID || index.ManifestList != existing.ManifestList {
|
||||
return index
|
||||
}
|
||||
if index.Compaction == nil && existing.Compaction != nil {
|
||||
compactionCopy := *existing.Compaction
|
||||
index.Compaction = &compactionCopy
|
||||
}
|
||||
if index.RewriteManifests == nil && existing.RewriteManifests != nil {
|
||||
rewriteCopy := *existing.RewriteManifests
|
||||
index.RewriteManifests = &rewriteCopy
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func buildPlanningIndexFromManifests(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, dataPath string,
|
||||
meta table.Metadata,
|
||||
config Config,
|
||||
ops []string,
|
||||
manifests []iceberg.ManifestFile,
|
||||
) (*planningIndex, error) {
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
index := &planningIndex{
|
||||
SnapshotID: currentSnap.SnapshotID,
|
||||
ManifestList: currentSnap.ManifestList,
|
||||
UpdatedAtMs: time.Now().UnixMilli(),
|
||||
DataManifestCount: countDataManifests(manifests),
|
||||
}
|
||||
|
||||
if operationRequested(ops, "compact") {
|
||||
eligible, err := hasEligibleCompaction(ctx, filerClient, bucketName, dataPath, manifests, config, meta, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index.Compaction = &planningIndexCompaction{
|
||||
ConfigHash: compactionPlanningConfigHash(config),
|
||||
Eligible: eligible,
|
||||
}
|
||||
}
|
||||
|
||||
if operationRequested(ops, "rewrite_manifests") {
|
||||
index.RewriteManifests = &planningIndexRewriteManifests{
|
||||
Threshold: config.MinManifestsToRewrite,
|
||||
Eligible: index.DataManifestCount >= config.MinManifestsToRewrite,
|
||||
}
|
||||
}
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func persistPlanningIndex(
|
||||
ctx context.Context,
|
||||
client filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
index *planningIndex,
|
||||
) error {
|
||||
if index == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
tableName := path.Base(tableDir)
|
||||
parentDir := path.Dir(tableDir)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDir,
|
||||
Name: tableName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup table entry: %w", err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return fmt.Errorf("table entry not found")
|
||||
}
|
||||
|
||||
existingXattr, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok || len(existingXattr) == 0 {
|
||||
return fmt.Errorf("no metadata xattr on table entry")
|
||||
}
|
||||
|
||||
var internalMeta map[string]json.RawMessage
|
||||
if err := json.Unmarshal(existingXattr, &internalMeta); err != nil {
|
||||
return fmt.Errorf("unmarshal metadata xattr: %w", err)
|
||||
}
|
||||
if existingState, err := parseTableMetadataEnvelope(existingXattr, bucketName, tablePath); err == nil {
|
||||
index = mergePlanningIndexSections(index, existingState.PlanningIndex)
|
||||
}
|
||||
|
||||
indexJSON, err := json.Marshal(index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal planning index: %w", err)
|
||||
}
|
||||
internalMeta["planningIndex"] = indexJSON
|
||||
|
||||
updatedXattr, err := json.Marshal(internalMeta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal updated metadata xattr: %w", err)
|
||||
}
|
||||
|
||||
expectedExtended := s3tables.SnapshotExtended(resp.Entry.Extended)
|
||||
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = updatedXattr
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDir,
|
||||
Entry: resp.Entry,
|
||||
ExpectedExtended: expectedExtended,
|
||||
})
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.FailedPrecondition {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("update table entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
22
weed/worker/tasks/iceberg/platform_test.go
Normal file
22
weed/worker/tasks/iceberg/platform_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// skipIfEqualityDeleteAvroUnsupported skips the test when running on a 32-bit
|
||||
// architecture. The upstream github.com/apache/iceberg-go library declares
|
||||
// manifestEntry.EqualityIDs as *[]int, but the Iceberg Avro schema declares
|
||||
// equality_ids as a long array. hamba/avro rejects []int for Avro long when
|
||||
// int is 32-bit (386, arm, mips), causing these tests to fail through no
|
||||
// fault of seaweedfs. Tracked upstream; remove this guard once iceberg-go
|
||||
// switches the field to []int32/[]int64.
|
||||
func skipIfEqualityDeleteAvroUnsupported(t *testing.T) {
|
||||
t.Helper()
|
||||
if unsafe.Sizeof(int(0)) < 8 {
|
||||
t.Skip("equality-delete Avro decoding is broken on 32-bit platforms " +
|
||||
"because github.com/apache/iceberg-go declares EqualityIDs as *[]int " +
|
||||
"but the Iceberg schema requires Avro long")
|
||||
}
|
||||
}
|
||||
111
weed/worker/tasks/iceberg/resource_groups.go
Normal file
111
weed/worker/tasks/iceberg/resource_groups.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
const (
|
||||
resourceGroupNone = "none"
|
||||
resourceGroupBucket = "bucket"
|
||||
resourceGroupNamespace = "namespace"
|
||||
resourceGroupBucketNamespace = "bucket_namespace"
|
||||
)
|
||||
|
||||
type resourceGroupConfig struct {
|
||||
GroupBy string
|
||||
MaxTablesPerGroup int64
|
||||
}
|
||||
|
||||
func readResourceGroupConfig(values map[string]*plugin_pb.ConfigValue) (resourceGroupConfig, error) {
|
||||
groupBy := strings.TrimSpace(strings.ToLower(readStringConfig(values, "resource_group_by", "")))
|
||||
if groupBy == "" {
|
||||
groupBy = resourceGroupNone
|
||||
}
|
||||
|
||||
switch groupBy {
|
||||
case resourceGroupNone, resourceGroupBucket, resourceGroupNamespace, resourceGroupBucketNamespace:
|
||||
default:
|
||||
return resourceGroupConfig{}, fmt.Errorf("invalid resource_group_by %q (valid: none, bucket, namespace, bucket_namespace)", groupBy)
|
||||
}
|
||||
|
||||
maxTablesPerGroup := readInt64Config(values, "max_tables_per_resource_group", 0)
|
||||
if maxTablesPerGroup < 0 {
|
||||
return resourceGroupConfig{}, fmt.Errorf("max_tables_per_resource_group must be >= 0, got %d", maxTablesPerGroup)
|
||||
}
|
||||
if groupBy == resourceGroupNone && maxTablesPerGroup > 0 {
|
||||
return resourceGroupConfig{}, fmt.Errorf("max_tables_per_resource_group requires resource_group_by to be set")
|
||||
}
|
||||
|
||||
return resourceGroupConfig{
|
||||
GroupBy: groupBy,
|
||||
MaxTablesPerGroup: maxTablesPerGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c resourceGroupConfig) enabled() bool {
|
||||
return c.GroupBy != "" && c.GroupBy != resourceGroupNone
|
||||
}
|
||||
|
||||
func resourceGroupKey(info tableInfo, groupBy string) string {
|
||||
switch groupBy {
|
||||
case resourceGroupBucket:
|
||||
return info.BucketName
|
||||
case resourceGroupNamespace:
|
||||
return info.Namespace
|
||||
case resourceGroupBucketNamespace:
|
||||
return info.BucketName + "/" + info.Namespace
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func selectTablesByResourceGroup(tables []tableInfo, cfg resourceGroupConfig, maxResults int) ([]tableInfo, bool) {
|
||||
if !cfg.enabled() {
|
||||
if maxResults > 0 && len(tables) > maxResults {
|
||||
return tables[:maxResults], true
|
||||
}
|
||||
return tables, false
|
||||
}
|
||||
|
||||
grouped := make(map[string][]tableInfo)
|
||||
groupOrder := make([]string, 0)
|
||||
for _, table := range tables {
|
||||
key := resourceGroupKey(table, cfg.GroupBy)
|
||||
if _, ok := grouped[key]; !ok {
|
||||
groupOrder = append(groupOrder, key)
|
||||
}
|
||||
grouped[key] = append(grouped[key], table)
|
||||
}
|
||||
|
||||
selected := make([]tableInfo, 0, len(tables))
|
||||
selectedPerGroup := make(map[string]int64)
|
||||
for {
|
||||
progress := false
|
||||
for _, key := range groupOrder {
|
||||
if maxResults > 0 && len(selected) >= maxResults {
|
||||
return selected, len(selected) < len(tables)
|
||||
}
|
||||
if cfg.MaxTablesPerGroup > 0 && selectedPerGroup[key] >= cfg.MaxTablesPerGroup {
|
||||
continue
|
||||
}
|
||||
|
||||
queue := grouped[key]
|
||||
if len(queue) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
selected = append(selected, queue[0])
|
||||
grouped[key] = queue[1:]
|
||||
selectedPerGroup[key]++
|
||||
progress = true
|
||||
}
|
||||
if !progress {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return selected, len(selected) < len(tables)
|
||||
}
|
||||
152
weed/worker/tasks/iceberg/snapshot_summary.go
Normal file
152
weed/worker/tasks/iceberg/snapshot_summary.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
)
|
||||
|
||||
// Snapshot summary property names from the Iceberg spec. iceberg-go keeps its
|
||||
// own copies unexported, so the maintenance operations spell them here.
|
||||
const (
|
||||
summaryAddedDataFiles = "added-data-files"
|
||||
summaryDeletedDataFiles = "deleted-data-files"
|
||||
summaryAddedDeleteFiles = "added-delete-files"
|
||||
summaryRemovedDeleteFiles = "removed-delete-files"
|
||||
summaryAddedPosDeleteFiles = "added-position-delete-files"
|
||||
summaryRemovedPosDelFiles = "removed-position-delete-files"
|
||||
summaryAddedEqDeleteFiles = "added-equality-delete-files"
|
||||
summaryRemovedEqDelFiles = "removed-equality-delete-files"
|
||||
summaryAddedRecords = "added-records"
|
||||
summaryDeletedRecords = "deleted-records"
|
||||
summaryAddedPosDeletes = "added-position-deletes"
|
||||
summaryRemovedPosDeletes = "removed-position-deletes"
|
||||
summaryAddedEqDeletes = "added-equality-deletes"
|
||||
summaryRemovedEqDeletes = "removed-equality-deletes"
|
||||
summaryAddedFilesSize = "added-files-size"
|
||||
summaryRemovedFilesSize = "removed-files-size"
|
||||
|
||||
summaryTotalDataFiles = "total-data-files"
|
||||
summaryTotalDeleteFiles = "total-delete-files"
|
||||
summaryTotalRecords = "total-records"
|
||||
summaryTotalFilesSize = "total-files-size"
|
||||
summaryTotalPosDeletes = "total-position-deletes"
|
||||
summaryTotalEqDeletes = "total-equality-deletes"
|
||||
)
|
||||
|
||||
// snapshotSummary accumulates the files a maintenance operation adds to and
|
||||
// removes from a table, and renders them as an Iceberg snapshot summary.
|
||||
//
|
||||
// Engines read a table's size straight out of the current snapshot summary —
|
||||
// PyIceberg's inspect.snapshots, Trino's $snapshots and Spark's DESCRIBE all
|
||||
// report total-records, total-data-files and total-files-size verbatim — so a
|
||||
// maintenance snapshot carrying only its own labels blanks those numbers for
|
||||
// the whole table until the next writer commits.
|
||||
type snapshotSummary struct {
|
||||
addedFilesSize, removedFilesSize int64
|
||||
addedDataFiles, removedDataFiles int64
|
||||
addedRecords, removedRecords int64
|
||||
addedDeleteFiles, removedDeleteFiles int64
|
||||
addedPosDeleteFiles, removedPosFiles int64
|
||||
addedEqDeleteFiles, removedEqFiles int64
|
||||
addedPosDeletes, removedPosDeletes int64
|
||||
addedEqDeletes, removedEqDeletes int64
|
||||
}
|
||||
|
||||
func (s *snapshotSummary) addFile(df iceberg.DataFile) {
|
||||
s.addedFilesSize += df.FileSizeBytes()
|
||||
switch df.ContentType() {
|
||||
case iceberg.EntryContentData:
|
||||
s.addedDataFiles++
|
||||
s.addedRecords += df.Count()
|
||||
case iceberg.EntryContentPosDeletes:
|
||||
s.addedDeleteFiles++
|
||||
s.addedPosDeleteFiles++
|
||||
s.addedPosDeletes += df.Count()
|
||||
case iceberg.EntryContentEqDeletes:
|
||||
s.addedDeleteFiles++
|
||||
s.addedEqDeleteFiles++
|
||||
s.addedEqDeletes += df.Count()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *snapshotSummary) removeFile(df iceberg.DataFile) {
|
||||
s.removedFilesSize += df.FileSizeBytes()
|
||||
switch df.ContentType() {
|
||||
case iceberg.EntryContentData:
|
||||
s.removedDataFiles++
|
||||
s.removedRecords += df.Count()
|
||||
case iceberg.EntryContentPosDeletes:
|
||||
s.removedDeleteFiles++
|
||||
s.removedPosFiles++
|
||||
s.removedPosDeletes += df.Count()
|
||||
case iceberg.EntryContentEqDeletes:
|
||||
s.removedDeleteFiles++
|
||||
s.removedEqFiles++
|
||||
s.removedEqDeletes += df.Count()
|
||||
}
|
||||
}
|
||||
|
||||
// build renders the summary for a snapshot replacing parent: the operation's
|
||||
// own labels, the added/removed counters, and the running totals.
|
||||
//
|
||||
// A total is carried forward only when the parent recorded it. Iceberg treats a
|
||||
// missing total as zero, which turns a compaction that replaces two files with
|
||||
// one into "total-data-files: -1" — dropped as negative — or worse, a table
|
||||
// with millions of rows into "total-records: 0". Leaving the field out lets a
|
||||
// reader fall back to the manifests instead of believing a made-up number.
|
||||
func (s snapshotSummary) build(operation table.Operation, parent *table.Snapshot, labels map[string]string) *table.Summary {
|
||||
props := iceberg.Properties{}
|
||||
for k, v := range labels {
|
||||
props[k] = v
|
||||
}
|
||||
|
||||
setPositive := func(key string, value int64) {
|
||||
if value > 0 {
|
||||
props[key] = strconv.FormatInt(value, 10)
|
||||
}
|
||||
}
|
||||
setPositive(summaryAddedFilesSize, s.addedFilesSize)
|
||||
setPositive(summaryRemovedFilesSize, s.removedFilesSize)
|
||||
setPositive(summaryAddedDataFiles, s.addedDataFiles)
|
||||
setPositive(summaryDeletedDataFiles, s.removedDataFiles)
|
||||
setPositive(summaryAddedRecords, s.addedRecords)
|
||||
setPositive(summaryDeletedRecords, s.removedRecords)
|
||||
setPositive(summaryAddedDeleteFiles, s.addedDeleteFiles)
|
||||
setPositive(summaryRemovedDeleteFiles, s.removedDeleteFiles)
|
||||
setPositive(summaryAddedPosDeleteFiles, s.addedPosDeleteFiles)
|
||||
setPositive(summaryRemovedPosDelFiles, s.removedPosFiles)
|
||||
setPositive(summaryAddedEqDeleteFiles, s.addedEqDeleteFiles)
|
||||
setPositive(summaryRemovedEqDelFiles, s.removedEqFiles)
|
||||
setPositive(summaryAddedPosDeletes, s.addedPosDeletes)
|
||||
setPositive(summaryRemovedPosDeletes, s.removedPosDeletes)
|
||||
setPositive(summaryAddedEqDeletes, s.addedEqDeletes)
|
||||
setPositive(summaryRemovedEqDeletes, s.removedEqDeletes)
|
||||
|
||||
var previous iceberg.Properties
|
||||
if parent != nil && parent.Summary != nil {
|
||||
previous = parent.Summary.Properties
|
||||
}
|
||||
carryTotal := func(key string, added, removed int64) {
|
||||
raw, ok := previous[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
before, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if total := before + added - removed; total >= 0 {
|
||||
props[key] = strconv.FormatInt(total, 10)
|
||||
}
|
||||
}
|
||||
carryTotal(summaryTotalDataFiles, s.addedDataFiles, s.removedDataFiles)
|
||||
carryTotal(summaryTotalDeleteFiles, s.addedDeleteFiles, s.removedDeleteFiles)
|
||||
carryTotal(summaryTotalRecords, s.addedRecords, s.removedRecords)
|
||||
carryTotal(summaryTotalFilesSize, s.addedFilesSize, s.removedFilesSize)
|
||||
carryTotal(summaryTotalPosDeletes, s.addedPosDeletes, s.removedPosDeletes)
|
||||
carryTotal(summaryTotalEqDeletes, s.addedEqDeletes, s.removedEqDeletes)
|
||||
|
||||
return &table.Summary{Operation: operation, Properties: props}
|
||||
}
|
||||
161
weed/worker/tasks/iceberg/sort_merge_test.go
Normal file
161
weed/worker/tasks/iceberg/sort_merge_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// seedSortableTable creates a table sorted ascending on id whose data files are
|
||||
// deliberately out of order: ids interleave across the files and descend within
|
||||
// each one, so neither the file order nor the row order is already sorted.
|
||||
func seedSortableTable(t *testing.T, files, rowsPerFile int) (*fakeFilerServer, filer_pb.SeaweedFilerClient, tableSetup) {
|
||||
t.Helper()
|
||||
|
||||
fs, client := startFakeFiler(t)
|
||||
sortOrder, err := table.NewSortOrder(1, []table.SortField{{
|
||||
SourceIDs: []int{1},
|
||||
Transform: iceberg.IdentityTransform{},
|
||||
Direction: table.SortASC,
|
||||
NullOrder: table.NullsFirst,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("new sort order: %v", err)
|
||||
}
|
||||
|
||||
type dataRow = struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
dataFiles := make([]struct {
|
||||
Name string
|
||||
Rows []dataRow
|
||||
}, 0, files)
|
||||
for f := 0; f < files; f++ {
|
||||
rows := make([]dataRow, 0, rowsPerFile)
|
||||
for i := 0; i < rowsPerFile; i++ {
|
||||
id := int64((rowsPerFile-1-i)*files + f)
|
||||
rows = append(rows, dataRow{ID: id, Name: fmt.Sprintf("row-%d", id)})
|
||||
}
|
||||
dataFiles = append(dataFiles, struct {
|
||||
Name string
|
||||
Rows []dataRow
|
||||
}{Name: fmt.Sprintf("d%d.parquet", f), Rows: rows})
|
||||
}
|
||||
|
||||
setup := tableSetup{BucketName: "tb", Namespace: "ns", TableName: "tbl"}
|
||||
populateTableWithDeleteFilesAndSortOrder(t, fs, setup, dataFiles, nil, nil, sortOrder)
|
||||
return fs, client, setup
|
||||
}
|
||||
|
||||
func sortCompactionConfig(spillDir string) Config {
|
||||
return Config{
|
||||
TargetFileSizeBytes: 256 * 1024 * 1024,
|
||||
MinInputFiles: 2,
|
||||
MaxCommitRetries: 3,
|
||||
ApplyDeletes: true,
|
||||
RewriteStrategy: "sort",
|
||||
SortBufferRows: minSortBufferRows,
|
||||
SortSpillDir: spillDir,
|
||||
}
|
||||
}
|
||||
|
||||
// compactedFileNames lists the outputs a compaction wrote, which is empty when
|
||||
// every bin was skipped.
|
||||
func compactedFileNames(fs *fakeFilerServer, setup tableSetup) []string {
|
||||
dataDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "data")
|
||||
var names []string
|
||||
for _, e := range fs.listDir(dataDir) {
|
||||
if strings.HasPrefix(e.Name, "compact-") {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// A sorted rewrite whose bin does not fit in one buffer has to spill: each
|
||||
// buffer is encoded as a sorted run and the runs are merged at close. This is
|
||||
// the case that used to hold every row of the bin in memory, so it is worth
|
||||
// proving both that the output is globally ordered and that the runs do not
|
||||
// outlive the job.
|
||||
func TestCompactDataFilesSortSpillsRunsAndCleansUp(t *testing.T) {
|
||||
const files, rowsPerFile = 3, 1200 // 3600 rows against a 1024-row buffer
|
||||
fs, client, setup := seedSortableTable(t, files, rowsPerFile)
|
||||
|
||||
spillDir := t.TempDir()
|
||||
handler := NewHandler(nil)
|
||||
result, _, err := handler.compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), sortCompactionConfig(spillDir), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("compactDataFiles: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "using sort") {
|
||||
t.Fatalf("expected sorted compaction result, got %q", result)
|
||||
}
|
||||
|
||||
rows := readCompactedRows(t, fs, setup)
|
||||
if len(rows) != files*rowsPerFile {
|
||||
t.Fatalf("expected %d compacted rows, got %d", files*rowsPerFile, len(rows))
|
||||
}
|
||||
for i := 1; i < len(rows); i++ {
|
||||
if rows[i-1].ID > rows[i].ID {
|
||||
t.Fatalf("rows are not sorted by id at %d: %d then %d", i, rows[i-1].ID, rows[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
leftovers, err := os.ReadDir(spillDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read spill dir: %v", err)
|
||||
}
|
||||
if len(leftovers) != 0 {
|
||||
t.Fatalf("expected the sorted runs to be removed, found %d file(s)", len(leftovers))
|
||||
}
|
||||
}
|
||||
|
||||
// An empty spill directory at the end of a job cannot tell a run written to
|
||||
// disk from one held in memory. A directory that cannot hold the runs can: the
|
||||
// same data compacts with a usable one and does not with an unusable one, which
|
||||
// it would not do if the runs never reached the pool. A bin whose merge fails
|
||||
// is logged and skipped rather than failing the job, so the difference shows up
|
||||
// as a missing output file rather than an error.
|
||||
func TestCompactDataFilesSortSpillDirDecidesOutcome(t *testing.T) {
|
||||
// Two files that together cross the 1024-row buffer, so a run is spilled.
|
||||
const files, rowsPerFile = 2, 700
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
spillDir func(t *testing.T) string
|
||||
wantOutcomes int
|
||||
}{
|
||||
{
|
||||
name: "usable",
|
||||
spillDir: func(t *testing.T) string { return t.TempDir() },
|
||||
wantOutcomes: 1,
|
||||
},
|
||||
{
|
||||
name: "unusable",
|
||||
spillDir: func(t *testing.T) string { return filepath.Join(t.TempDir(), "not-created") },
|
||||
wantOutcomes: 0,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fs, client, setup := seedSortableTable(t, files, rowsPerFile)
|
||||
handler := NewHandler(nil)
|
||||
|
||||
if _, _, err := handler.compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), sortCompactionConfig(tc.spillDir(t)), nil); err != nil {
|
||||
t.Fatalf("compactDataFiles: %v", err)
|
||||
}
|
||||
if names := compactedFileNames(fs, setup); len(names) != tc.wantOutcomes {
|
||||
t.Fatalf("expected %d compacted file(s), got %v", tc.wantOutcomes, names)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
202
weed/worker/tasks/iceberg/sort_strategy.go
Normal file
202
weed/worker/tasks/iceberg/sort_strategy.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
type compactionRewritePlan struct {
|
||||
strategy string
|
||||
sortFields []compactionSortField
|
||||
// Carried on the plan so the merge does not need the whole Config.
|
||||
bufferRows int64
|
||||
spillDir string
|
||||
}
|
||||
|
||||
type compactionSortField struct {
|
||||
path string
|
||||
descending bool
|
||||
nullsFirst bool
|
||||
}
|
||||
|
||||
func resolveCompactionRewritePlan(config Config, meta table.Metadata) (*compactionRewritePlan, error) {
|
||||
strategy := strings.TrimSpace(strings.ToLower(config.RewriteStrategy))
|
||||
if strategy == "" {
|
||||
strategy = defaultRewriteStrategy
|
||||
}
|
||||
if strategy == defaultRewriteStrategy {
|
||||
return &compactionRewritePlan{strategy: defaultRewriteStrategy}, nil
|
||||
}
|
||||
// "auto" sorts tables that declare a usable sort order and bin-packs the
|
||||
// rest, so an unsorted table is not an error the way an explicit "sort" is.
|
||||
if strategy == rewriteStrategyAuto {
|
||||
sortFields, err := resolveCompactionSortFields(meta)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg compact: auto strategy falling back to binpack: %v", err)
|
||||
return &compactionRewritePlan{strategy: defaultRewriteStrategy}, nil
|
||||
}
|
||||
return newSortPlan(config, sortFields), nil
|
||||
}
|
||||
if strategy != "sort" {
|
||||
return nil, fmt.Errorf("unsupported rewrite strategy %q", config.RewriteStrategy)
|
||||
}
|
||||
|
||||
sortFields, err := resolveCompactionSortFields(meta)
|
||||
if err != nil {
|
||||
if err == errUnsupportedTableSortOrder {
|
||||
glog.V(2).Infof("iceberg compact: falling back to binpack because the table sort order is not supported")
|
||||
return &compactionRewritePlan{strategy: defaultRewriteStrategy}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newSortPlan(config, sortFields), nil
|
||||
}
|
||||
|
||||
// newSortPlan pairs the resolved sort fields with the settings the sorted
|
||||
// merge needs while writing.
|
||||
func newSortPlan(config Config, sortFields []compactionSortField) *compactionRewritePlan {
|
||||
return &compactionRewritePlan{
|
||||
strategy: "sort",
|
||||
sortFields: sortFields,
|
||||
bufferRows: config.SortBufferRows,
|
||||
spillDir: config.SortSpillDir,
|
||||
}
|
||||
}
|
||||
|
||||
var errUnsupportedTableSortOrder = fmt.Errorf("unsupported table sort order")
|
||||
|
||||
func resolveCompactionSortFields(meta table.Metadata) ([]compactionSortField, error) {
|
||||
if meta == nil || meta.CurrentSchema() == nil {
|
||||
return nil, fmt.Errorf("sort rewrite requires table schema metadata")
|
||||
}
|
||||
|
||||
sortOrder := meta.SortOrder()
|
||||
if sortOrder.IsUnsorted() {
|
||||
return nil, fmt.Errorf("sort rewrite requires a table sort order")
|
||||
}
|
||||
|
||||
schema := meta.CurrentSchema()
|
||||
seen := make(map[string]struct{})
|
||||
fields := make([]compactionSortField, 0, sortOrder.Len())
|
||||
for _, sortField := range sortOrder.Fields() {
|
||||
if _, ok := sortField.Transform.(iceberg.IdentityTransform); !ok {
|
||||
return nil, errUnsupportedTableSortOrder
|
||||
}
|
||||
|
||||
field, ok := schema.FindFieldByID(sortField.SourceID())
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("table sort field %d not found in schema", sortField.SourceID())
|
||||
}
|
||||
if _, ok := field.Type.(iceberg.PrimitiveType); !ok {
|
||||
return nil, fmt.Errorf("table sort field %q is not a primitive column", field.Name)
|
||||
}
|
||||
|
||||
columnPath, ok := schema.FindColumnName(sortField.SourceID())
|
||||
if !ok {
|
||||
columnPath = field.Name
|
||||
}
|
||||
normalizedPath := strings.ToLower(columnPath)
|
||||
if _, exists := seen[normalizedPath]; exists {
|
||||
return nil, fmt.Errorf("duplicate sort field %q", columnPath)
|
||||
}
|
||||
seen[normalizedPath] = struct{}{}
|
||||
|
||||
fields = append(fields, compactionSortField{
|
||||
path: columnPath,
|
||||
descending: sortField.Direction == table.SortDESC,
|
||||
nullsFirst: sortField.NullOrder == table.NullsFirst,
|
||||
})
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
return nil, fmt.Errorf("sort rewrite requires at least one sort field")
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func compactionTargetSizeForPlan(config Config, plan *compactionRewritePlan) int64 {
|
||||
targetSize := config.TargetFileSizeBytes
|
||||
if plan != nil && plan.strategy == "sort" && config.SortMaxInputBytes > 0 && config.SortMaxInputBytes < targetSize {
|
||||
return config.SortMaxInputBytes
|
||||
}
|
||||
return targetSize
|
||||
}
|
||||
|
||||
func filterCompactionBinsByPlan(bins []compactionBin, config Config, plan *compactionRewritePlan) []compactionBin {
|
||||
if plan == nil || plan.strategy != "sort" || config.SortMaxInputBytes <= 0 {
|
||||
return bins
|
||||
}
|
||||
|
||||
filtered := make([]compactionBin, 0, len(bins))
|
||||
for _, bin := range bins {
|
||||
if bin.TotalSize <= config.SortMaxInputBytes {
|
||||
filtered = append(filtered, bin)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func compactionNoEligibleMessage(config Config, plan *compactionRewritePlan, initialBinCount int) string {
|
||||
if plan != nil && plan.strategy == "sort" && config.SortMaxInputBytes > 0 && initialBinCount > 0 {
|
||||
return fmt.Sprintf("no files eligible for sorted compaction within %d MB sort input cap", config.SortMaxInputBytes/bytesPerMB)
|
||||
}
|
||||
return "no files eligible for compaction"
|
||||
}
|
||||
|
||||
func (p *compactionRewritePlan) summaryLabel() string {
|
||||
if p == nil || p.strategy != "sort" {
|
||||
return defaultRewriteStrategy
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(p.sortFields))
|
||||
for _, field := range p.sortFields {
|
||||
label := field.path
|
||||
if field.descending {
|
||||
label += " desc"
|
||||
} else {
|
||||
label += " asc"
|
||||
}
|
||||
if field.nullsFirst {
|
||||
label += " nulls-first"
|
||||
} else {
|
||||
label += " nulls-last"
|
||||
}
|
||||
paths = append(paths, label)
|
||||
}
|
||||
return fmt.Sprintf("sort (%s)", strings.Join(paths, ", "))
|
||||
}
|
||||
|
||||
func (p *compactionRewritePlan) parquetSortingColumns(pqSchema *parquet.Schema) ([]parquet.SortingColumn, error) {
|
||||
if p == nil || p.strategy != "sort" {
|
||||
return nil, nil
|
||||
}
|
||||
if pqSchema == nil {
|
||||
return nil, fmt.Errorf("parquet schema is required for sort rewrite")
|
||||
}
|
||||
|
||||
columns := make([]parquet.SortingColumn, 0, len(p.sortFields))
|
||||
for _, field := range p.sortFields {
|
||||
path := strings.Split(field.path, ".")
|
||||
if _, ok := pqSchema.Lookup(path...); !ok {
|
||||
return nil, fmt.Errorf("parquet schema missing sort field %q", field.path)
|
||||
}
|
||||
|
||||
var column parquet.SortingColumn
|
||||
if field.descending {
|
||||
column = parquet.Descending(path...)
|
||||
} else {
|
||||
column = parquet.Ascending(path...)
|
||||
}
|
||||
if field.nullsFirst {
|
||||
column = parquet.NullsFirst(column)
|
||||
}
|
||||
columns = append(columns, column)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
233
weed/worker/tasks/iceberg/table_config.go
Normal file
233
weed/worker/tasks/iceberg/table_config.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// Iceberg table properties that describe the physical layout maintenance has
|
||||
// to produce.
|
||||
const (
|
||||
propTargetFileSize = "write.target-file-size-bytes"
|
||||
propDeleteTargetFileSize = "write.delete.target-file-size-bytes"
|
||||
propMaxSnapshotAgeMs = "history.expire.max-snapshot-age-ms"
|
||||
propMinSnapshotsToKeep = "history.expire.min-snapshots-to-keep"
|
||||
)
|
||||
|
||||
// resolveTableConfig layers a table's own intent over the worker config, from
|
||||
// the maintenance configuration and from the table's Iceberg properties.
|
||||
//
|
||||
// Properties win by default: a writer targeting 512 MiB and a compactor
|
||||
// rewriting to 256 MiB would rewrite each other's output forever, and the
|
||||
// property is the signal every engine already honours. Operators who want the
|
||||
// control plane to be authoritative instead can clear
|
||||
// table_properties_override.
|
||||
func resolveTableConfig(base Config, props iceberg.Properties, maintenance s3tables.MaintenanceConfiguration) Config {
|
||||
cfg := base
|
||||
if base.TablePropertiesOverride {
|
||||
cfg = applyMaintenanceConfig(cfg, maintenance)
|
||||
cfg = applyTableProperties(cfg, props)
|
||||
} else {
|
||||
cfg = applyTableProperties(cfg, props)
|
||||
cfg = applyMaintenanceConfig(cfg, maintenance)
|
||||
}
|
||||
return applyThresholdDefaults(cfg)
|
||||
}
|
||||
|
||||
func applyTableProperties(cfg Config, props iceberg.Properties) Config {
|
||||
if v, ok := propInt64(props, propTargetFileSize); ok {
|
||||
cfg.TargetFileSizeBytes = v
|
||||
}
|
||||
if v, ok := propInt64(props, propDeleteTargetFileSize); ok {
|
||||
cfg.DeleteTargetFileSizeBytes = v
|
||||
}
|
||||
if v, ok := propInt64(props, propMaxSnapshotAgeMs); ok {
|
||||
cfg.SnapshotRetentionMs = v
|
||||
}
|
||||
if v, ok := propInt64(props, propMinSnapshotsToKeep); ok {
|
||||
cfg.MaxSnapshotsToKeep = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func applyMaintenanceConfig(cfg Config, maintenance s3tables.MaintenanceConfiguration) Config {
|
||||
if s := compactionSettings(maintenance); s != nil {
|
||||
if v := settingValue(s.TargetFileSizeMB); v > 0 {
|
||||
cfg.TargetFileSizeBytes = mbToBytes(v)
|
||||
}
|
||||
switch s.Strategy {
|
||||
case s3tables.CompactionStrategyBinpack, s3tables.CompactionStrategySort, s3tables.CompactionStrategyAuto:
|
||||
cfg.RewriteStrategy = s.Strategy
|
||||
}
|
||||
}
|
||||
if s := snapshotSettings(maintenance); s != nil {
|
||||
if v := settingValue(s.MaxSnapshotAgeHours); v > 0 {
|
||||
cfg.SnapshotRetentionMs = hoursToMs(v)
|
||||
}
|
||||
if v := settingValue(s.MinSnapshotsToKeep); v > 0 {
|
||||
cfg.MaxSnapshotsToKeep = v
|
||||
}
|
||||
}
|
||||
// AWS marks a file non-current after unreferencedDays and deletes it a
|
||||
// further nonCurrentDays later. remove_orphans deletes in one step, so the
|
||||
// cutoff has to be the sum or the recovery window disappears.
|
||||
if s := orphanSettings(maintenance); s != nil {
|
||||
if days := addDays(settingValue(s.UnreferencedDays), settingValue(s.NonCurrentDays)); days > 0 {
|
||||
cfg.OrphanOlderThanHours = daysToHours(days)
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// settingValue reads an optional maintenance setting, treating unset and
|
||||
// nonsensical values alike so the worker keeps its own configuration.
|
||||
func settingValue(v *int64) int64 {
|
||||
if v == nil || *v < 0 {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func compactionSettings(maintenance s3tables.MaintenanceConfiguration) *s3tables.IcebergCompactionSettings {
|
||||
if v := enabledSettings(maintenance, s3tables.MaintenanceTypeIcebergCompaction); v != nil {
|
||||
return v.IcebergCompaction
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotSettings(maintenance s3tables.MaintenanceConfiguration) *s3tables.IcebergSnapshotManagementSettings {
|
||||
if v := enabledSettings(maintenance, s3tables.MaintenanceTypeIcebergSnapshotManagement); v != nil {
|
||||
return v.IcebergSnapshotManagement
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orphanSettings(maintenance s3tables.MaintenanceConfiguration) *s3tables.IcebergUnreferencedFileRemovalSettings {
|
||||
if v := enabledSettings(maintenance, s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval); v != nil {
|
||||
return v.IcebergUnreferencedFileRemoval
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enabledSettings returns the settings for a maintenance type only when it is
|
||||
// switched on; a disabled type drops its operations instead of retuning them.
|
||||
func enabledSettings(maintenance s3tables.MaintenanceConfiguration, maintenanceType string) *s3tables.MaintenanceSettings {
|
||||
value, ok := maintenance[maintenanceType]
|
||||
if !ok || value == nil || value.Status == s3tables.MaintenanceStatusDisabled {
|
||||
return nil
|
||||
}
|
||||
return value.Settings
|
||||
}
|
||||
|
||||
var mergeMaintenanceConfiguration = s3tables.MergeMaintenanceConfiguration
|
||||
|
||||
// maintenanceJobType maps a worker operation onto the AWS job type that governs
|
||||
// it. Manifest and delete-file rewrites have no AWS equivalent and ride along
|
||||
// with compaction, so disabling compaction stops all rewriting.
|
||||
func maintenanceJobType(op string) string {
|
||||
switch op {
|
||||
case "compact", "rewrite_position_delete_files", "rewrite_manifests":
|
||||
return s3tables.MaintenanceTypeIcebergCompaction
|
||||
case "expire_snapshots":
|
||||
return s3tables.MaintenanceTypeIcebergSnapshotManagement
|
||||
case "remove_orphans":
|
||||
return s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// filterDisabledOperations drops the operations the maintenance configuration
|
||||
// switched off. No table property can re-enable them.
|
||||
func filterDisabledOperations(ops []string, maintenance s3tables.MaintenanceConfiguration) []string {
|
||||
if len(maintenance) == 0 {
|
||||
return ops
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
value, ok := maintenance[maintenanceJobType(op)]
|
||||
if ok && value != nil && value.Status == s3tables.MaintenanceStatusDisabled {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, op)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// loadMaintenanceConfiguration reads the bucket and table maintenance
|
||||
// configuration and overlays the table's on the bucket's. Detection gets both
|
||||
// from entries it already listed; execution has to look them up.
|
||||
func loadMaintenanceConfiguration(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string) (s3tables.MaintenanceConfiguration, error) {
|
||||
bucketDir := path.Join(s3tables.TablesPath, bucketName)
|
||||
tableDir := path.Join(bucketDir, tablePath)
|
||||
|
||||
bucket, err := lookupMaintenanceConfiguration(ctx, client, bucketDir, bucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table, err := lookupMaintenanceConfiguration(ctx, client, tableDir, path.Join(bucketName, tablePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeMaintenanceConfiguration(bucket, table), nil
|
||||
}
|
||||
|
||||
func lookupMaintenanceConfiguration(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, resource string) (s3tables.MaintenanceConfiguration, error) {
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: path.Dir(dir),
|
||||
Name: path.Base(dir),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("look up %s: %w", resource, err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return parseMaintenanceConfiguration(resp.Entry.Extended, resource)
|
||||
}
|
||||
|
||||
// parseMaintenanceConfiguration reads the maintenance configuration stored on a
|
||||
// filer entry. An unreadable attribute is an error rather than an empty
|
||||
// configuration: treating it as empty would run operations the operator
|
||||
// disabled.
|
||||
func parseMaintenanceConfiguration(extended map[string][]byte, resource string) (s3tables.MaintenanceConfiguration, error) {
|
||||
data, ok := extended[s3tables.ExtendedKeyMaintenance]
|
||||
if !ok || len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var config s3tables.MaintenanceConfiguration
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parse maintenance configuration on %s: %w", resource, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// propInt64 reads a positive integer table property. Anything unset,
|
||||
// unparseable or non-positive leaves the worker config in place so a
|
||||
// misconfigured table never stops its own maintenance.
|
||||
func propInt64(props iceberg.Properties, key string) (int64, bool) {
|
||||
raw, ok := props[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
glog.V(1).Infof("iceberg maintenance: ignoring table property %s=%q: not an integer", key, raw)
|
||||
return 0, false
|
||||
}
|
||||
if value <= 0 {
|
||||
glog.V(1).Infof("iceberg maintenance: ignoring table property %s=%d: not positive", key, value)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
406
weed/worker/tasks/iceberg/table_config_test.go
Normal file
406
weed/worker/tasks/iceberg/table_config_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func settingPtr(v int64) *int64 { return &v }
|
||||
|
||||
func baseTestConfig() Config {
|
||||
return applyThresholdDefaults(Config{
|
||||
SnapshotRetentionMs: hoursToMs(defaultSnapshotRetentionHours),
|
||||
MaxSnapshotsToKeep: defaultMaxSnapshotsToKeep,
|
||||
TablePropertiesOverride: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveTableConfigNoProperties(t *testing.T) {
|
||||
base := baseTestConfig()
|
||||
|
||||
got := resolveTableConfig(base, iceberg.Properties{}, nil)
|
||||
if got != base {
|
||||
t.Errorf("expected config untouched, got %+v", got)
|
||||
}
|
||||
|
||||
if got := resolveTableConfig(base, nil, nil); got != base {
|
||||
t.Errorf("expected config untouched for nil properties, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTableConfigPropertiesWin(t *testing.T) {
|
||||
base := baseTestConfig()
|
||||
got := resolveTableConfig(base, iceberg.Properties{
|
||||
propTargetFileSize: "536870912",
|
||||
propDeleteTargetFileSize: "33554432",
|
||||
propMaxSnapshotAgeMs: "432000000",
|
||||
propMinSnapshotsToKeep: "1",
|
||||
}, nil)
|
||||
|
||||
if got.TargetFileSizeBytes != 536870912 {
|
||||
t.Errorf("expected TargetFileSizeBytes=536870912, got %d", got.TargetFileSizeBytes)
|
||||
}
|
||||
if got.DeleteTargetFileSizeBytes != 33554432 {
|
||||
t.Errorf("expected DeleteTargetFileSizeBytes=33554432, got %d", got.DeleteTargetFileSizeBytes)
|
||||
}
|
||||
if got.SnapshotRetentionMs != 432000000 {
|
||||
t.Errorf("expected SnapshotRetentionMs=432000000, got %d", got.SnapshotRetentionMs)
|
||||
}
|
||||
if got.MaxSnapshotsToKeep != 1 {
|
||||
t.Errorf("expected MaxSnapshotsToKeep=1, got %d", got.MaxSnapshotsToKeep)
|
||||
}
|
||||
}
|
||||
|
||||
// A sub-hour retention has to survive resolution; truncating it to whole hours
|
||||
// would round down to zero and get clamped back up to the default.
|
||||
func TestResolveTableConfigSubHourRetention(t *testing.T) {
|
||||
got := resolveTableConfig(baseTestConfig(), iceberg.Properties{propMaxSnapshotAgeMs: "1"}, nil)
|
||||
if got.SnapshotRetentionMs != 1 {
|
||||
t.Errorf("expected SnapshotRetentionMs=1, got %d", got.SnapshotRetentionMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTableConfigIgnoresUnusableValues(t *testing.T) {
|
||||
base := baseTestConfig()
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"not a number": "512mb",
|
||||
"empty": "",
|
||||
"zero": "0",
|
||||
"negative": "-1",
|
||||
"overflow": "99999999999999999999",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := resolveTableConfig(base, iceberg.Properties{propTargetFileSize: value}, nil)
|
||||
if got.TargetFileSizeBytes != base.TargetFileSizeBytes {
|
||||
t.Errorf("expected fallback to %d, got %d", base.TargetFileSizeBytes, got.TargetFileSizeBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTableConfigTrimsWhitespace(t *testing.T) {
|
||||
got := resolveTableConfig(baseTestConfig(), iceberg.Properties{propTargetFileSize: " 536870912\n"}, nil)
|
||||
if got.TargetFileSizeBytes != 536870912 {
|
||||
t.Errorf("expected TargetFileSizeBytes=536870912, got %d", got.TargetFileSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTableConfigLeavesOtherFields(t *testing.T) {
|
||||
base := baseTestConfig()
|
||||
base.Operations = "compact"
|
||||
base.Where = "day = 3"
|
||||
base.MinInputFiles = 9
|
||||
|
||||
got := resolveTableConfig(base, iceberg.Properties{propTargetFileSize: "536870912"}, nil)
|
||||
if got.Operations != "compact" || got.Where != "day = 3" || got.MinInputFiles != 9 {
|
||||
t.Errorf("expected unrelated fields preserved, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An orphan cutoff large enough to overflow time.Duration would wrap negative,
|
||||
// put the cutoff in the future and make every file look like an orphan.
|
||||
func TestApplyThresholdDefaultsClampsOrphanCutoff(t *testing.T) {
|
||||
got := applyThresholdDefaults(Config{OrphanOlderThanHours: math.MaxInt64})
|
||||
if got.OrphanOlderThanHours != maxOrphanOlderThanHours {
|
||||
t.Fatalf("expected the cutoff clamped to %d, got %d", maxOrphanOlderThanHours, got.OrphanOlderThanHours)
|
||||
}
|
||||
if cutoff := time.Duration(got.OrphanOlderThanHours) * time.Hour; cutoff <= 0 {
|
||||
t.Errorf("expected a positive cutoff duration, got %v", cutoff)
|
||||
}
|
||||
|
||||
if got := applyThresholdDefaults(Config{OrphanOlderThanHours: 72}); got.OrphanOlderThanHours != 72 {
|
||||
t.Errorf("expected a normal cutoff untouched, got %d", got.OrphanOlderThanHours)
|
||||
}
|
||||
}
|
||||
|
||||
func maintenanceConfig(entries map[string]*s3tables.MaintenanceConfigurationValue) s3tables.MaintenanceConfiguration {
|
||||
return s3tables.MaintenanceConfiguration(entries)
|
||||
}
|
||||
|
||||
func TestResolveTableConfigAppliesMaintenanceConfig(t *testing.T) {
|
||||
got := resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{TargetFileSizeMB: settingPtr(512)}},
|
||||
},
|
||||
s3tables.MaintenanceTypeIcebergSnapshotManagement: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{IcebergSnapshotManagement: &s3tables.IcebergSnapshotManagementSettings{
|
||||
MinSnapshotsToKeep: settingPtr(2),
|
||||
MaxSnapshotAgeHours: settingPtr(120),
|
||||
}},
|
||||
},
|
||||
}))
|
||||
|
||||
if got.TargetFileSizeBytes != 512*bytesPerMB {
|
||||
t.Errorf("expected TargetFileSizeBytes=%d, got %d", 512*bytesPerMB, got.TargetFileSizeBytes)
|
||||
}
|
||||
if got.MaxSnapshotsToKeep != 2 {
|
||||
t.Errorf("expected MaxSnapshotsToKeep=2, got %d", got.MaxSnapshotsToKeep)
|
||||
}
|
||||
if got.SnapshotRetentionMs != hoursToMs(120) {
|
||||
t.Errorf("expected SnapshotRetentionMs=%d, got %d", hoursToMs(120), got.SnapshotRetentionMs)
|
||||
}
|
||||
}
|
||||
|
||||
// The default: a table declaring its own layout beats the control plane, so a
|
||||
// writer and the compactor cannot disagree about target file size.
|
||||
func TestResolveTableConfigPropertyBeatsMaintenanceConfig(t *testing.T) {
|
||||
maintenance := maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{TargetFileSizeMB: settingPtr(128)}},
|
||||
},
|
||||
})
|
||||
props := iceberg.Properties{propTargetFileSize: "536870912"}
|
||||
|
||||
got := resolveTableConfig(baseTestConfig(), props, maintenance)
|
||||
if got.TargetFileSizeBytes != 536870912 {
|
||||
t.Errorf("expected the property to win with 536870912, got %d", got.TargetFileSizeBytes)
|
||||
}
|
||||
|
||||
base := baseTestConfig()
|
||||
base.TablePropertiesOverride = false
|
||||
got = resolveTableConfig(base, props, maintenance)
|
||||
if got.TargetFileSizeBytes != 128*bytesPerMB {
|
||||
t.Errorf("expected the maintenance config to win with %d, got %d", 128*bytesPerMB, got.TargetFileSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled type drops its operations rather than contributing settings.
|
||||
func TestResolveTableConfigIgnoresDisabledSettings(t *testing.T) {
|
||||
base := baseTestConfig()
|
||||
got := resolveTableConfig(base, nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {
|
||||
Status: s3tables.MaintenanceStatusDisabled,
|
||||
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{TargetFileSizeMB: settingPtr(512)}},
|
||||
},
|
||||
}))
|
||||
|
||||
if got.TargetFileSizeBytes != base.TargetFileSizeBytes {
|
||||
t.Errorf("expected disabled settings ignored, got %d", got.TargetFileSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDisabledOperations(t *testing.T) {
|
||||
all := []string{"compact", "rewrite_position_delete_files", "expire_snapshots", "remove_orphans", "rewrite_manifests"}
|
||||
|
||||
if got := filterDisabledOperations(all, nil); len(got) != len(all) {
|
||||
t.Errorf("expected no filtering without a configuration, got %v", got)
|
||||
}
|
||||
|
||||
got := filterDisabledOperations(all, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusDisabled},
|
||||
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {Status: s3tables.MaintenanceStatusDisabled},
|
||||
}))
|
||||
want := []string{"expire_snapshots"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Errorf("expected %v, got %v", want, got)
|
||||
}
|
||||
|
||||
if got := filterDisabledOperations(all, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusEnabled},
|
||||
})); len(got) != len(all) {
|
||||
t.Errorf("expected an enabled type to filter nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMaintenanceConfigurationTableWins(t *testing.T) {
|
||||
bucket := maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {Status: s3tables.MaintenanceStatusEnabled},
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusEnabled},
|
||||
})
|
||||
table := maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {Status: s3tables.MaintenanceStatusDisabled},
|
||||
})
|
||||
|
||||
merged := mergeMaintenanceConfiguration(bucket, table)
|
||||
if merged[s3tables.MaintenanceTypeIcebergCompaction].Status != s3tables.MaintenanceStatusDisabled {
|
||||
t.Error("expected the table entry to win")
|
||||
}
|
||||
if merged[s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval].Status != s3tables.MaintenanceStatusEnabled {
|
||||
t.Error("expected the bucket-only entry retained")
|
||||
}
|
||||
|
||||
if got := mergeMaintenanceConfiguration(nil, table); len(got) != 1 {
|
||||
t.Errorf("expected the table configuration passed through, got %v", got)
|
||||
}
|
||||
if got := mergeMaintenanceConfiguration(bucket, nil); len(got) != 2 {
|
||||
t.Errorf("expected the bucket configuration passed through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaintenanceConfiguration(t *testing.T) {
|
||||
got, err := parseMaintenanceConfiguration(nil, "bucket")
|
||||
if err != nil || got != nil {
|
||||
t.Errorf("expected nil for a missing attribute, got %v (%v)", got, err)
|
||||
}
|
||||
|
||||
// Unreadable must be an error, not an empty configuration: empty would run
|
||||
// operations the operator disabled.
|
||||
if _, err := parseMaintenanceConfiguration(map[string][]byte{s3tables.ExtendedKeyMaintenance: []byte("{")}, "bucket"); err == nil {
|
||||
t.Error("expected an error for a malformed attribute")
|
||||
}
|
||||
|
||||
data := []byte(`{"icebergCompaction":{"status":"disabled"}}`)
|
||||
got, err = parseMaintenanceConfiguration(map[string][]byte{s3tables.ExtendedKeyMaintenance: data}, "bucket")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got[s3tables.MaintenanceTypeIcebergCompaction].Status != s3tables.MaintenanceStatusDisabled {
|
||||
t.Errorf("expected compaction disabled, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// unreferencedDays large enough to overflow must not land a cutoff in the future.
|
||||
func TestResolveTableConfigClampsUnreferencedDays(t *testing.T) {
|
||||
got := resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{
|
||||
IcebergUnreferencedFileRemoval: &s3tables.IcebergUnreferencedFileRemovalSettings{UnreferencedDays: settingPtr(math.MaxInt64)},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
if got.OrphanOlderThanHours != maxOrphanOlderThanHours {
|
||||
t.Fatalf("expected the cutoff clamped to %d, got %d", maxOrphanOlderThanHours, got.OrphanOlderThanHours)
|
||||
}
|
||||
if cutoff := time.Duration(got.OrphanOlderThanHours) * time.Hour; cutoff <= 0 {
|
||||
t.Errorf("expected a positive cutoff duration, got %v", cutoff)
|
||||
}
|
||||
|
||||
got = resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{
|
||||
IcebergUnreferencedFileRemoval: &s3tables.IcebergUnreferencedFileRemovalSettings{UnreferencedDays: settingPtr(3)},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if got.OrphanOlderThanHours != 72 {
|
||||
t.Errorf("expected 3 days to be 72 hours, got %d", got.OrphanOlderThanHours)
|
||||
}
|
||||
}
|
||||
|
||||
// AWS marks a file non-current after unreferencedDays and deletes it
|
||||
// nonCurrentDays later; deleting in one step has to wait for both.
|
||||
func TestResolveTableConfigAddsNonCurrentWindow(t *testing.T) {
|
||||
orphan := func(unreferenced, nonCurrent int64) Config {
|
||||
return resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{
|
||||
IcebergUnreferencedFileRemoval: &s3tables.IcebergUnreferencedFileRemovalSettings{
|
||||
UnreferencedDays: settingPtr(unreferenced),
|
||||
NonCurrentDays: settingPtr(nonCurrent),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if got := orphan(3, 10); got.OrphanOlderThanHours != 13*24 {
|
||||
t.Errorf("expected 3+10 days to be %d hours, got %d", 13*24, got.OrphanOlderThanHours)
|
||||
}
|
||||
if got := orphan(0, 10); got.OrphanOlderThanHours != 10*24 {
|
||||
t.Errorf("expected nonCurrentDays alone to apply, got %d", got.OrphanOlderThanHours)
|
||||
}
|
||||
if got := orphan(math.MaxInt64, math.MaxInt64); got.OrphanOlderThanHours != maxOrphanOlderThanHours {
|
||||
t.Errorf("expected the sum clamped, got %d", got.OrphanOlderThanHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildJobStatus(t *testing.T) {
|
||||
ranAt := time.Unix(1755250000, 0).UTC()
|
||||
|
||||
jobStatus := buildJobStatus(map[string]error{
|
||||
"compact": nil,
|
||||
"expire_snapshots": nil,
|
||||
}, ranAt)
|
||||
|
||||
compaction := jobStatus[s3tables.MaintenanceTypeIcebergCompaction]
|
||||
if compaction == nil || compaction.Status != s3tables.MaintenanceJobStatusSuccessful {
|
||||
t.Fatalf("expected compaction successful, got %+v", compaction)
|
||||
}
|
||||
if compaction.LastRunTimestamp == nil || !compaction.LastRunTimestamp.Equal(ranAt) {
|
||||
t.Errorf("expected lastRunTimestamp %v, got %v", ranAt, compaction.LastRunTimestamp)
|
||||
}
|
||||
if _, ok := jobStatus[s3tables.MaintenanceTypeIcebergUnreferencedFileRemoval]; ok {
|
||||
t.Error("expected an operation that did not run to be left out")
|
||||
}
|
||||
}
|
||||
|
||||
// Several operations share one AWS job type, so one failure fails the type.
|
||||
func TestBuildJobStatusFailureWins(t *testing.T) {
|
||||
jobStatus := buildJobStatus(map[string]error{
|
||||
"compact": nil,
|
||||
"rewrite_manifests": errors.New("commit conflict"),
|
||||
}, time.Unix(1755250000, 0).UTC())
|
||||
|
||||
compaction := jobStatus[s3tables.MaintenanceTypeIcebergCompaction]
|
||||
if compaction.Status != s3tables.MaintenanceJobStatusFailed {
|
||||
t.Errorf("expected compaction failed, got %q", compaction.Status)
|
||||
}
|
||||
if compaction.FailureMessage != "commit conflict" {
|
||||
t.Errorf("expected the failure message carried, got %q", compaction.FailureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildJobStatusEmpty(t *testing.T) {
|
||||
if got := buildJobStatus(nil, time.Now()); len(got) != 0 {
|
||||
t.Errorf("expected an empty status, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTableConfigAppliesCompactionStrategy(t *testing.T) {
|
||||
withStrategy := func(strategy string) Config {
|
||||
return resolveTableConfig(baseTestConfig(), nil, maintenanceConfig(map[string]*s3tables.MaintenanceConfigurationValue{
|
||||
s3tables.MaintenanceTypeIcebergCompaction: {
|
||||
Status: s3tables.MaintenanceStatusEnabled,
|
||||
Settings: &s3tables.MaintenanceSettings{IcebergCompaction: &s3tables.IcebergCompactionSettings{Strategy: strategy}},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if got := withStrategy(s3tables.CompactionStrategySort); got.RewriteStrategy != "sort" {
|
||||
t.Errorf("expected sort, got %q", got.RewriteStrategy)
|
||||
}
|
||||
if got := withStrategy(s3tables.CompactionStrategyBinpack); got.RewriteStrategy != "binpack" {
|
||||
t.Errorf("expected binpack, got %q", got.RewriteStrategy)
|
||||
}
|
||||
|
||||
// "auto" is carried through so the plan can pick per table.
|
||||
if got := withStrategy(s3tables.CompactionStrategyAuto); got.RewriteStrategy != rewriteStrategyAuto {
|
||||
t.Errorf("expected auto preserved, got %q", got.RewriteStrategy)
|
||||
}
|
||||
}
|
||||
|
||||
// AWS defines auto as sorting tables that declare a sort order and bin-packing
|
||||
// the rest, so it must not collapse to the worker default.
|
||||
func TestResolveCompactionRewritePlanAuto(t *testing.T) {
|
||||
cfg := baseTestConfig()
|
||||
cfg.RewriteStrategy = rewriteStrategyAuto
|
||||
|
||||
unsorted := buildTestMetadata(t, nil, nil, 0, nil, nil, nil)
|
||||
plan, err := resolveCompactionRewritePlan(cfg, unsorted)
|
||||
if err != nil {
|
||||
t.Fatalf("auto must not fail on an unsorted table: %v", err)
|
||||
}
|
||||
if plan.strategy != defaultRewriteStrategy {
|
||||
t.Errorf("expected binpack for an unsorted table, got %q", plan.strategy)
|
||||
}
|
||||
|
||||
// An explicit sort request on the same table is still an error, so auto is
|
||||
// doing something the existing strategies did not.
|
||||
cfg.RewriteStrategy = "sort"
|
||||
if _, err := resolveCompactionRewritePlan(cfg, unsorted); err == nil {
|
||||
t.Error("expected explicit sort to fail without a table sort order")
|
||||
}
|
||||
}
|
||||
BIN
weed/worker/tasks/iceberg/testdata/plain-dictionary.parquet
vendored
Normal file
BIN
weed/worker/tasks/iceberg/testdata/plain-dictionary.parquet
vendored
Normal file
Binary file not shown.
26
weed/worker/tasks/iceberg/testing_api.go
Normal file
26
weed/worker/tasks/iceberg/testing_api.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
// The following methods export the internal maintenance operations for use
|
||||
// by integration tests. They are intentionally thin wrappers.
|
||||
|
||||
func (h *Handler) ExpireSnapshots(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.expireSnapshots(ctx, client, bucketName, tablePath, config)
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveOrphans(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.removeOrphans(ctx, client, bucketName, tablePath, config)
|
||||
}
|
||||
|
||||
func (h *Handler) RewriteManifests(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.rewriteManifests(ctx, client, bucketName, tablePath, config)
|
||||
}
|
||||
|
||||
func (h *Handler) CompactDataFiles(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.compactDataFiles(ctx, client, bucketName, tablePath, config, nil)
|
||||
}
|
||||
311
weed/worker/tasks/iceberg/where_filter.go
Normal file
311
weed/worker/tasks/iceberg/where_filter.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
)
|
||||
|
||||
var (
|
||||
whereEqualsPattern = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
|
||||
whereInPattern = regexp.MustCompile(`^(?i)([A-Za-z_][A-Za-z0-9_]*)\s+IN\s*\((.*)\)$`)
|
||||
)
|
||||
|
||||
type whereClause struct {
|
||||
Field string
|
||||
Literals []string
|
||||
}
|
||||
|
||||
type partitionPredicate struct {
|
||||
Clauses []whereClause
|
||||
}
|
||||
|
||||
func validateWhereOperations(where string, ops []string) error {
|
||||
if strings.TrimSpace(where) == "" {
|
||||
return nil
|
||||
}
|
||||
for _, op := range ops {
|
||||
switch op {
|
||||
case "compact", "rewrite_manifests", "rewrite_position_delete_files":
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("where filter is only supported for compact, rewrite_position_delete_files, and rewrite_manifests")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePartitionPredicate(where string, meta table.Metadata) (*partitionPredicate, error) {
|
||||
where = strings.TrimSpace(where)
|
||||
if where == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, fmt.Errorf("where filter requires table metadata")
|
||||
}
|
||||
|
||||
specs := meta.PartitionSpecs()
|
||||
if len(specs) == 0 || meta.PartitionSpec().IsUnpartitioned() {
|
||||
return nil, fmt.Errorf("where filter is not supported for unpartitioned tables")
|
||||
}
|
||||
|
||||
rawClauses := splitWhereConjunction(where)
|
||||
clauses := make([]whereClause, 0, len(rawClauses))
|
||||
for _, raw := range rawClauses {
|
||||
clause, err := parseWhereClause(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauses = append(clauses, clause)
|
||||
}
|
||||
|
||||
// Validate against the current partition spec only. Historical specs may
|
||||
// lack fields added during schema evolution; per-entry matching in Matches()
|
||||
// handles those gracefully.
|
||||
currentSpec := meta.PartitionSpec()
|
||||
for _, clause := range clauses {
|
||||
if !specHasFieldByName(currentSpec, clause.Field) {
|
||||
return nil, fmt.Errorf("where field %q is not present in current partition spec %d", clause.Field, currentSpec.ID())
|
||||
}
|
||||
}
|
||||
|
||||
return &partitionPredicate{Clauses: clauses}, nil
|
||||
}
|
||||
|
||||
func splitWhereConjunction(where string) []string {
|
||||
// Quote-aware split: only split on AND that appears outside quotes.
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
var quote rune
|
||||
runes := []rune(where)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if quote != 0 {
|
||||
current.WriteRune(r)
|
||||
if r == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r == '\'' || r == '"' {
|
||||
quote = r
|
||||
current.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
// Check for case-insensitive AND surrounded by whitespace.
|
||||
if (r == 'A' || r == 'a') && i+3 < len(runes) {
|
||||
candidate := string(runes[i : i+3])
|
||||
if strings.EqualFold(candidate, "AND") {
|
||||
before := i > 0 && isWhitespace(runes[i-1])
|
||||
after := i+3 < len(runes) && isWhitespace(runes[i+3])
|
||||
if before && after {
|
||||
part := strings.TrimSpace(current.String())
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
current.Reset()
|
||||
i += 3 // skip "AND" + the after-space will be consumed next iteration
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
current.WriteRune(r)
|
||||
}
|
||||
if part := strings.TrimSpace(current.String()); part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func isWhitespace(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
}
|
||||
|
||||
func parseWhereClause(raw string) (whereClause, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return whereClause{}, fmt.Errorf("empty where clause")
|
||||
}
|
||||
if matches := whereInPattern.FindStringSubmatch(raw); matches != nil {
|
||||
literals, err := splitLiteralList(matches[2])
|
||||
if err != nil {
|
||||
return whereClause{}, err
|
||||
}
|
||||
if len(literals) == 0 {
|
||||
return whereClause{}, fmt.Errorf("empty IN list in where clause %q", raw)
|
||||
}
|
||||
return whereClause{Field: matches[1], Literals: literals}, nil
|
||||
}
|
||||
if matches := whereEqualsPattern.FindStringSubmatch(raw); matches != nil {
|
||||
return whereClause{Field: matches[1], Literals: []string{strings.TrimSpace(matches[2])}}, nil
|
||||
}
|
||||
return whereClause{}, fmt.Errorf("unsupported where clause %q", raw)
|
||||
}
|
||||
|
||||
func splitLiteralList(raw string) ([]string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var (
|
||||
literals []string
|
||||
current strings.Builder
|
||||
quote rune
|
||||
)
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case quote != 0:
|
||||
current.WriteRune(r)
|
||||
if r == quote {
|
||||
quote = 0
|
||||
}
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
current.WriteRune(r)
|
||||
case r == ',':
|
||||
literal := strings.TrimSpace(current.String())
|
||||
if literal != "" {
|
||||
literals = append(literals, literal)
|
||||
}
|
||||
current.Reset()
|
||||
default:
|
||||
current.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, fmt.Errorf("unterminated quoted literal in IN list")
|
||||
}
|
||||
if literal := strings.TrimSpace(current.String()); literal != "" {
|
||||
literals = append(literals, literal)
|
||||
}
|
||||
return literals, nil
|
||||
}
|
||||
|
||||
func specHasFieldByName(spec iceberg.PartitionSpec, fieldName string) bool {
|
||||
for _, field := range spec.Fields() {
|
||||
if field.Name == fieldName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func specByID(meta table.Metadata) map[int]iceberg.PartitionSpec {
|
||||
result := make(map[int]iceberg.PartitionSpec)
|
||||
if meta == nil {
|
||||
return result
|
||||
}
|
||||
for _, spec := range meta.PartitionSpecs() {
|
||||
result[spec.ID()] = spec
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *partitionPredicate) Matches(spec iceberg.PartitionSpec, partition map[int]any) (bool, error) {
|
||||
if p == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
valuesByName := make(map[string]any)
|
||||
for _, field := range spec.Fields() {
|
||||
if value, ok := partition[field.FieldID]; ok {
|
||||
valuesByName[field.Name] = value
|
||||
}
|
||||
}
|
||||
|
||||
for _, clause := range p.Clauses {
|
||||
actual, ok := valuesByName[clause.Field]
|
||||
if !ok {
|
||||
// Field not present in this spec (e.g. older spec before schema
|
||||
// evolution). Skip this entry rather than erroring.
|
||||
return false, nil
|
||||
}
|
||||
matched := false
|
||||
for _, literal := range clause.Literals {
|
||||
ok, err := literalMatchesActual(literal, actual)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("where field %q: %w", clause.Field, err)
|
||||
}
|
||||
if ok {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func literalMatchesActual(raw string, actual any) (bool, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return false, fmt.Errorf("empty literal")
|
||||
}
|
||||
|
||||
switch v := actual.(type) {
|
||||
case string:
|
||||
value, err := unquoteLiteral(raw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v == value, nil
|
||||
case bool:
|
||||
value, err := strconv.ParseBool(strings.ToLower(strings.TrimSpace(raw)))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse bool literal %q: %w", raw, err)
|
||||
}
|
||||
return v == value, nil
|
||||
case int:
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse int literal %q: %w", raw, err)
|
||||
}
|
||||
return int64(v) == value, nil
|
||||
case int32:
|
||||
value, err := strconv.ParseInt(raw, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse int32 literal %q: %w", raw, err)
|
||||
}
|
||||
return v == int32(value), nil
|
||||
case int64:
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse int64 literal %q: %w", raw, err)
|
||||
}
|
||||
return v == value, nil
|
||||
case float32:
|
||||
value, err := strconv.ParseFloat(raw, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse float32 literal %q: %w", raw, err)
|
||||
}
|
||||
return v == float32(value), nil
|
||||
case float64:
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse float64 literal %q: %w", raw, err)
|
||||
}
|
||||
return v == value, nil
|
||||
default:
|
||||
value, err := unquoteLiteral(raw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return fmt.Sprint(actual) == value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func unquoteLiteral(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) >= 2 {
|
||||
if (raw[0] == '\'' && raw[len(raw)-1] == '\'') || (raw[0] == '"' && raw[len(raw)-1] == '"') {
|
||||
return raw[1 : len(raw)-1], nil
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
287
weed/worker/tasks/iceberg/where_filter_test.go
Normal file
287
weed/worker/tasks/iceberg/where_filter_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
type partitionedTestFile struct {
|
||||
Name string
|
||||
Partition map[int]any
|
||||
Rows []struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
}
|
||||
|
||||
func populatePartitionedDataTable(
|
||||
t *testing.T,
|
||||
fs *fakeFilerServer,
|
||||
setup tableSetup,
|
||||
partitionSpec iceberg.PartitionSpec,
|
||||
manifestGroups [][]partitionedTestFile,
|
||||
) table.Metadata {
|
||||
t.Helper()
|
||||
|
||||
schema := newTestSchema()
|
||||
meta, err := table.NewMetadata(schema, &partitionSpec, table.UnsortedSortOrder, "s3://"+setup.BucketName+"/"+setup.tablePath(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create metadata: %v", err)
|
||||
}
|
||||
|
||||
bucketsPath := s3tables.TablesPath
|
||||
bucketPath := path.Join(bucketsPath, setup.BucketName)
|
||||
nsPath := path.Join(bucketPath, setup.Namespace)
|
||||
tablePath := path.Join(nsPath, setup.TableName)
|
||||
metaDir := path.Join(tablePath, "metadata")
|
||||
dataDir := path.Join(tablePath, "data")
|
||||
|
||||
version := meta.Version()
|
||||
var manifestFiles []iceberg.ManifestFile
|
||||
for idx, group := range manifestGroups {
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(group))
|
||||
for _, file := range group {
|
||||
data := writeTestParquetFile(t, fs, dataDir, file.Name, file.Rows)
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
partitionSpec,
|
||||
iceberg.EntryContentData,
|
||||
path.Join("data", file.Name),
|
||||
iceberg.ParquetFile,
|
||||
file.Partition,
|
||||
nil, nil,
|
||||
int64(len(file.Rows)),
|
||||
int64(len(data)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build data file %s: %v", file.Name, err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entries = append(entries, iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build()))
|
||||
}
|
||||
|
||||
manifestName := fmt.Sprintf("where-manifest-%d.avro", idx+1)
|
||||
var manifestBuf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(path.Join("metadata", manifestName), &manifestBuf, version, partitionSpec, schema, 1, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("write manifest %d: %v", idx+1, err)
|
||||
}
|
||||
fs.putEntry(metaDir, manifestName, &filer_pb.Entry{
|
||||
Name: manifestName,
|
||||
Content: manifestBuf.Bytes(),
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix(), FileSize: uint64(manifestBuf.Len())},
|
||||
})
|
||||
manifestFiles = append(manifestFiles, mf)
|
||||
}
|
||||
|
||||
var manifestListBuf bytes.Buffer
|
||||
seqNum := int64(1)
|
||||
if err := iceberg.WriteManifestList(version, &manifestListBuf, 1, nil, &seqNum, 0, manifestFiles); err != nil {
|
||||
t.Fatalf("write manifest list: %v", err)
|
||||
}
|
||||
fs.putEntry(metaDir, "snap-1.avro", &filer_pb.Entry{
|
||||
Name: "snap-1.avro",
|
||||
Content: manifestListBuf.Bytes(),
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix(), FileSize: uint64(manifestListBuf.Len())},
|
||||
})
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(meta, "s3://"+setup.BucketName+"/"+setup.tablePath())
|
||||
if err != nil {
|
||||
t.Fatalf("metadata builder: %v", err)
|
||||
}
|
||||
snapshot := table.Snapshot{SnapshotID: 1, TimestampMs: time.Now().UnixMilli(), ManifestList: "metadata/snap-1.avro", SequenceNumber: 1}
|
||||
if err := builder.AddSnapshot(&snapshot); err != nil {
|
||||
t.Fatalf("add snapshot: %v", err)
|
||||
}
|
||||
if err := builder.SetSnapshotRef(table.MainBranch, 1, table.BranchRef); err != nil {
|
||||
t.Fatalf("set snapshot ref: %v", err)
|
||||
}
|
||||
meta, err = builder.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("build metadata: %v", err)
|
||||
}
|
||||
|
||||
fullMetadataJSON, _ := json.Marshal(meta)
|
||||
internalMeta := map[string]interface{}{
|
||||
"metadataVersion": 1,
|
||||
"metadataLocation": "metadata/v1.metadata.json",
|
||||
"metadata": map[string]interface{}{"fullMetadata": json.RawMessage(fullMetadataJSON)},
|
||||
}
|
||||
xattr, _ := json.Marshal(internalMeta)
|
||||
|
||||
fs.putEntry(bucketsPath, setup.BucketName, &filer_pb.Entry{
|
||||
Name: setup.BucketName,
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{s3tables.ExtendedKeyTableBucket: []byte("true")},
|
||||
})
|
||||
fs.putEntry(bucketPath, setup.Namespace, &filer_pb.Entry{Name: setup.Namespace, IsDirectory: true})
|
||||
fs.putEntry(nsPath, setup.TableName, &filer_pb.Entry{
|
||||
Name: setup.TableName,
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{
|
||||
s3tables.ExtendedKeyMetadata: xattr,
|
||||
s3tables.ExtendedKeyMetadataVersion: metadataVersionXattr(1),
|
||||
},
|
||||
})
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func TestValidateWhereOperations(t *testing.T) {
|
||||
if err := validateWhereOperations("name = 'us'", []string{"compact", "rewrite_manifests"}); err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
if err := validateWhereOperations("name = 'us'", []string{"expire_snapshots"}); err == nil {
|
||||
t.Fatal("expected where validation to reject expire_snapshots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitWhereConjunctionQuoteAware(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"a = 1 AND b = 2", []string{"a = 1", "b = 2"}},
|
||||
{"a = 'research AND dev'", []string{"a = 'research AND dev'"}},
|
||||
{"a IN ('sales AND marketing', 'eng') AND b = 2", []string{"a IN ('sales AND marketing', 'eng')", "b = 2"}},
|
||||
{"a = 1 and b = 2", []string{"a = 1", "b = 2"}},
|
||||
{"a = 'x' AND b = \"y AND z\"", []string{"a = 'x'", "b = \"y AND z\""}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := splitWhereConjunction(tc.input)
|
||||
if len(got) != len(tc.expected) {
|
||||
t.Errorf("splitWhereConjunction(%q) = %v, want %v", tc.input, got, tc.expected)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.expected[i] {
|
||||
t.Errorf("splitWhereConjunction(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionPredicateMatchesUsesPartitionFieldIDs(t *testing.T) {
|
||||
spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
|
||||
SourceIDs: []int{2},
|
||||
FieldID: 1000,
|
||||
Name: "name",
|
||||
Transform: iceberg.IdentityTransform{},
|
||||
})
|
||||
predicate := &partitionPredicate{Clauses: []whereClause{{Field: "name", Literals: []string{"'us'"}}}}
|
||||
|
||||
match, err := predicate.Matches(spec, map[int]any{2: "us"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Fatal("expected source-column key to not match partition predicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactDataFilesWhereFilter(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
partitionSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
|
||||
SourceIDs: []int{2},
|
||||
FieldID: 1000,
|
||||
Name: "name",
|
||||
Transform: iceberg.IdentityTransform{},
|
||||
})
|
||||
|
||||
setup := tableSetup{BucketName: "tb", Namespace: "ns", TableName: "tbl"}
|
||||
populatePartitionedDataTable(t, fs, setup, partitionSpec, [][]partitionedTestFile{
|
||||
{
|
||||
{Name: "us-1.parquet", Partition: map[int]any{1000: "us"}, Rows: []struct {
|
||||
ID int64
|
||||
Name string
|
||||
}{{1, "us"}}},
|
||||
},
|
||||
{
|
||||
{Name: "us-2.parquet", Partition: map[int]any{1000: "us"}, Rows: []struct {
|
||||
ID int64
|
||||
Name string
|
||||
}{{2, "us"}}},
|
||||
},
|
||||
{
|
||||
{Name: "eu-1.parquet", Partition: map[int]any{1000: "eu"}, Rows: []struct {
|
||||
ID int64
|
||||
Name string
|
||||
}{{3, "eu"}}},
|
||||
{Name: "eu-2.parquet", Partition: map[int]any{1000: "eu"}, Rows: []struct {
|
||||
ID int64
|
||||
Name string
|
||||
}{{4, "eu"}}},
|
||||
},
|
||||
})
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
TargetFileSizeBytes: 256 * 1024 * 1024,
|
||||
MinInputFiles: 2,
|
||||
MaxCommitRetries: 3,
|
||||
Where: "name = 'us'",
|
||||
}
|
||||
|
||||
result, _, err := handler.compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), config, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("compactDataFiles: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "compacted 2 files into 1") {
|
||||
t.Fatalf("unexpected result: %q", result)
|
||||
}
|
||||
|
||||
state, err := loadCurrentMetadata(context.Background(), client, setup.BucketName, setup.tablePath())
|
||||
if err != nil {
|
||||
t.Fatalf("loadCurrentMetadata: %v", err)
|
||||
}
|
||||
manifests, err := loadCurrentManifests(context.Background(), client, setup.BucketName, state.DataPath, state.Metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("loadCurrentManifests: %v", err)
|
||||
}
|
||||
|
||||
var liveDataPaths []string
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
manifestData, err := loadFileByIcebergPath(context.Background(), client, setup.BucketName, setup.tablePath(), mf.FilePath())
|
||||
if err != nil {
|
||||
t.Fatalf("load data manifest: %v", err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
t.Fatalf("read data manifest: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
liveDataPaths = append(liveDataPaths, entry.DataFile().FilePath())
|
||||
}
|
||||
}
|
||||
|
||||
if len(liveDataPaths) != 3 {
|
||||
t.Fatalf("expected 3 live data files after filtered compaction, got %v", liveDataPaths)
|
||||
}
|
||||
var compactedCount int
|
||||
for _, p := range liveDataPaths {
|
||||
switch {
|
||||
case strings.HasPrefix(p, "s3://tb/ns/tbl/data/compact-"):
|
||||
compactedCount++
|
||||
case p == "data/eu-1.parquet", p == "data/eu-2.parquet":
|
||||
default:
|
||||
t.Fatalf("unexpected live data file %q", p)
|
||||
}
|
||||
}
|
||||
if compactedCount != 1 {
|
||||
t.Fatalf("expected exactly one compacted file, got %d in %v", compactedCount, liveDataPaths)
|
||||
}
|
||||
}
|
||||
103
weed/worker/tasks/registry.go
Normal file
103
weed/worker/tasks/registry.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
var (
|
||||
globalTypesRegistry *types.TaskRegistry
|
||||
globalUIRegistry *types.UIRegistry
|
||||
globalTaskRegistry *TaskRegistry
|
||||
typesRegistryOnce sync.Once
|
||||
uiRegistryOnce sync.Once
|
||||
taskRegistryOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalTypesRegistry returns the global types registry (singleton)
|
||||
func GetGlobalTypesRegistry() *types.TaskRegistry {
|
||||
typesRegistryOnce.Do(func() {
|
||||
globalTypesRegistry = types.NewTaskRegistry()
|
||||
glog.V(1).Infof("Created global types registry")
|
||||
})
|
||||
return globalTypesRegistry
|
||||
}
|
||||
|
||||
// GetGlobalUIRegistry returns the global UI registry (singleton)
|
||||
func GetGlobalUIRegistry() *types.UIRegistry {
|
||||
uiRegistryOnce.Do(func() {
|
||||
globalUIRegistry = types.NewUIRegistry()
|
||||
glog.V(1).Infof("Created global UI registry")
|
||||
})
|
||||
return globalUIRegistry
|
||||
}
|
||||
|
||||
// GetGlobalTaskRegistry returns the global task registry (singleton)
|
||||
func GetGlobalTaskRegistry() *TaskRegistry {
|
||||
taskRegistryOnce.Do(func() {
|
||||
globalTaskRegistry = NewTaskRegistry()
|
||||
glog.V(1).Infof("Created global task registry")
|
||||
})
|
||||
return globalTaskRegistry
|
||||
}
|
||||
|
||||
// AutoRegister registers a task with the global task registry
|
||||
func AutoRegister(taskType types.TaskType, factory types.TaskFactory) {
|
||||
registry := GetGlobalTaskRegistry()
|
||||
registry.Register(taskType, factory)
|
||||
glog.V(1).Infof("Auto-registered task type: %s", taskType)
|
||||
}
|
||||
|
||||
// AutoRegisterTypes registers a task with the global types registry
|
||||
func AutoRegisterTypes(registerFunc func(*types.TaskRegistry)) {
|
||||
registry := GetGlobalTypesRegistry()
|
||||
registerFunc(registry)
|
||||
glog.V(1).Infof("Auto-registered task with types registry")
|
||||
}
|
||||
|
||||
// AutoRegisterUI registers a UI provider with the global UI registry
|
||||
func AutoRegisterUI(registerFunc func(*types.UIRegistry)) {
|
||||
registry := GetGlobalUIRegistry()
|
||||
registerFunc(registry)
|
||||
glog.V(1).Infof("Auto-registered task UI provider")
|
||||
}
|
||||
|
||||
// TaskRegistry manages task factories
|
||||
type TaskRegistry struct {
|
||||
factories map[types.TaskType]types.TaskFactory
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTaskRegistry creates a new task registry
|
||||
func NewTaskRegistry() *TaskRegistry {
|
||||
return &TaskRegistry{
|
||||
factories: make(map[types.TaskType]types.TaskFactory),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a factory to the registry
|
||||
func (r *TaskRegistry) Register(taskType types.TaskType, factory types.TaskFactory) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
r.factories[taskType] = factory
|
||||
}
|
||||
|
||||
// Get returns a factory from the registry
|
||||
func (r *TaskRegistry) Get(taskType types.TaskType) types.TaskFactory {
|
||||
r.mutex.RLock()
|
||||
defer r.mutex.RUnlock()
|
||||
return r.factories[taskType]
|
||||
}
|
||||
|
||||
// GetAll returns all registered factories
|
||||
func (r *TaskRegistry) GetAll() map[types.TaskType]types.TaskFactory {
|
||||
r.mutex.RLock()
|
||||
defer r.mutex.RUnlock()
|
||||
result := make(map[types.TaskType]types.TaskFactory)
|
||||
for k, v := range r.factories {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
54
weed/worker/tasks/s3_lifecycle/cluster_rate_limit.go
Normal file
54
weed/worker/tasks/s3_lifecycle/cluster_rate_limit.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package s3_lifecycle
|
||||
|
||||
// Cluster-wide rate-limit configuration plumbing for the daily-replay
|
||||
// worker. The admin holds a single "cluster delete budget" knob, divides
|
||||
// it by the number of execute-capable s3_lifecycle workers at job-dispatch
|
||||
// time, and ships the per-worker share to the worker via
|
||||
// ExecuteJobRequest.ClusterContext.Metadata. The worker reads the share,
|
||||
// constructs a rate.Limiter, and passes it to dailyrun.Run.
|
||||
//
|
||||
// These constants are the contract between admin (weed/admin/plugin/plugin.go
|
||||
// computes the share and writes the keys) and worker (this package's
|
||||
// handler.go reads them). Changing a name on one side without the other
|
||||
// would silently disable rate limiting — both sides must read these
|
||||
// exact values.
|
||||
|
||||
const (
|
||||
// ClusterDeletesPerSecondAdminKey is the admin-config field that
|
||||
// holds the cluster-wide budget in delete RPCs per second. 0 means
|
||||
// unlimited (legacy behavior). Set via the AdminConfigForm in
|
||||
// handler.go's "Scope" section.
|
||||
ClusterDeletesPerSecondAdminKey = "cluster_deletes_per_second"
|
||||
// ClusterDeletesBurstAdminKey holds the token-bucket burst. 0 means
|
||||
// "2 × rps" (computed by the admin allocator).
|
||||
ClusterDeletesBurstAdminKey = "cluster_deletes_burst"
|
||||
// MetaLogRetentionDaysAdminKey holds the operator's declaration of
|
||||
// how far back the filer's meta-log subscription can reliably reach.
|
||||
// Rules whose effective TTL exceeds this window can't be serviced by
|
||||
// replay alone and get partitioned into engine.PromotedHash's walk
|
||||
// set; a partition flip (operator shrinks retention) then trips the
|
||||
// recovery branch on the next run. 0 = unbounded (current behavior,
|
||||
// falls back to maxTTL in runShard so PromotedHash stays empty).
|
||||
MetaLogRetentionDaysAdminKey = "meta_log_retention_days"
|
||||
// WalkerIntervalMinutesAdminKey throttles the per-shard steady-state
|
||||
// and empty-replay walker fires. dailyrun.runShard checks the time
|
||||
// since the persisted Cursor.LastWalkedNs and skips the walk when
|
||||
// less than this interval has elapsed; cold-start and recovery walker
|
||||
// fires (RecoveryView) stay unconditional. 0 means "fire on every
|
||||
// run" (the prior behavior — appropriate when the worker is driven
|
||||
// at the operator's intended walk cadence, e.g. once per hour).
|
||||
// Production deployments running the worker at multi-second cadence
|
||||
// (CI ticks, sub-minute admin schedules) should set this to roughly
|
||||
// the per-shard walk budget — typically 60 (1h) for small clusters,
|
||||
// 360+ (6h+) for large ones.
|
||||
WalkerIntervalMinutesAdminKey = "walker_interval_minutes"
|
||||
|
||||
// MetadataKeyDeletesPerSecond is the per-worker share value the
|
||||
// admin writes into ClusterContext.Metadata at ExecuteJob time.
|
||||
// Stored as a string of a non-negative float64; empty/missing/zero
|
||||
// means "no rate limit on this run" (cfg.Limiter stays nil).
|
||||
MetadataKeyDeletesPerSecond = "s3_lifecycle.deletes_per_second"
|
||||
// MetadataKeyDeletesBurst is the per-worker burst share. Stored as
|
||||
// a string of a non-negative integer.
|
||||
MetadataKeyDeletesBurst = "s3_lifecycle.deletes_burst"
|
||||
)
|
||||
78
weed/worker/tasks/s3_lifecycle/cluster_rate_limit_test.go
Normal file
78
weed/worker/tasks/s3_lifecycle/cluster_rate_limit_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package s3_lifecycle
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildLimiterFromClusterContext_NilCC(t *testing.T) {
|
||||
l, desc := buildLimiterFromClusterContext(nil)
|
||||
assert.Nil(t, l)
|
||||
assert.Equal(t, "unlimited", desc)
|
||||
}
|
||||
|
||||
func TestBuildLimiterFromClusterContext_NoMetadata(t *testing.T) {
|
||||
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{})
|
||||
assert.Nil(t, l)
|
||||
assert.Equal(t, "unlimited", desc)
|
||||
}
|
||||
|
||||
func TestBuildLimiterFromClusterContext_MissingRateKey(t *testing.T) {
|
||||
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
|
||||
Metadata: map[string]string{"unrelated": "x"},
|
||||
})
|
||||
assert.Nil(t, l)
|
||||
assert.Equal(t, "unlimited", desc)
|
||||
}
|
||||
|
||||
func TestBuildLimiterFromClusterContext_NonPositiveRate(t *testing.T) {
|
||||
// 0 or negative rate means the admin didn't allocate; the worker
|
||||
// must NOT construct a limiter that throttles every request to
|
||||
// zero-throughput.
|
||||
for _, raw := range []string{"0", "-1", "0.0", "not-a-number", ""} {
|
||||
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
|
||||
Metadata: map[string]string{MetadataKeyDeletesPerSecond: raw},
|
||||
})
|
||||
assert.Nil(t, l, "rate=%q must yield nil limiter", raw)
|
||||
assert.Equal(t, "unlimited", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLimiterFromClusterContext_PositiveRateBuildsLimiter(t *testing.T) {
|
||||
l, desc := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
|
||||
Metadata: map[string]string{
|
||||
MetadataKeyDeletesPerSecond: "12.5",
|
||||
MetadataKeyDeletesBurst: "25",
|
||||
},
|
||||
})
|
||||
require.NotNil(t, l)
|
||||
assert.Equal(t, 12.5, float64(l.Limit()))
|
||||
assert.Equal(t, 25, l.Burst())
|
||||
assert.Contains(t, desc, "12.5/s")
|
||||
assert.Contains(t, desc, "burst=25")
|
||||
}
|
||||
|
||||
func TestBuildLimiterFromClusterContext_BurstMissingDefaultsTo2xRate(t *testing.T) {
|
||||
// burst omitted (admin allocator wrote nothing) → worker computes
|
||||
// 2 × rps so a single tick has headroom for two refills.
|
||||
l, _ := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
|
||||
Metadata: map[string]string{MetadataKeyDeletesPerSecond: "10"},
|
||||
})
|
||||
require.NotNil(t, l)
|
||||
assert.Equal(t, 10.0, float64(l.Limit()))
|
||||
assert.Equal(t, 20, l.Burst(), "burst default must be 2× rps")
|
||||
}
|
||||
|
||||
func TestBuildLimiterFromClusterContext_TinyRateClampsBurstToOne(t *testing.T) {
|
||||
// A sub-1-rps allocation (e.g. 0.5/s across few workers) would
|
||||
// compute 2 × 0.5 = 1, but if the int truncation produced 0 the
|
||||
// limiter would never refill. Clamp to at least 1.
|
||||
l, _ := buildLimiterFromClusterContext(&plugin_pb.ClusterContext{
|
||||
Metadata: map[string]string{MetadataKeyDeletesPerSecond: "0.1"},
|
||||
})
|
||||
require.NotNil(t, l)
|
||||
assert.GreaterOrEqual(t, l.Burst(), 1, "burst must never round down to 0")
|
||||
}
|
||||
62
weed/worker/tasks/s3_lifecycle/config.go
Normal file
62
weed/worker/tasks/s3_lifecycle/config.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package s3_lifecycle
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
const (
|
||||
jobType = "s3_lifecycle"
|
||||
|
||||
// In-process fan-out across the 16 shards.
|
||||
shardPipelineGoroutines = 1
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Workers int
|
||||
MetaLogRetention time.Duration
|
||||
// WalkerInterval is the minimum time between steady-state walker
|
||||
// fires per shard. 0 means "fire on every run", preserving prior
|
||||
// behavior; positive values gate the walker via Cursor.LastWalkedNs
|
||||
// inside dailyrun.runShard.
|
||||
WalkerInterval time.Duration
|
||||
}
|
||||
|
||||
func ParseConfig(adminValues map[string]*plugin_pb.ConfigValue, workerValues map[string]*plugin_pb.ConfigValue) Config {
|
||||
cfg := Config{
|
||||
Workers: shardPipelineGoroutines,
|
||||
}
|
||||
_ = workerValues // worker-side config form is currently empty; reserved for future per-worker tuning.
|
||||
// Operator-declared meta-log retention. Negative or zero values stay
|
||||
// zero so runShard falls back to maxTTL (PromotedHash dormant).
|
||||
// Convert days->hours in int64 space before lifting to time.Duration
|
||||
// so the unit is unambiguous.
|
||||
if days := readInt64(adminValues, MetaLogRetentionDaysAdminKey, 0); days > 0 {
|
||||
cfg.MetaLogRetention = time.Duration(days*24) * time.Hour
|
||||
}
|
||||
// Walker throttle. Negative / zero stay zero so dailyrun.runShard
|
||||
// keeps the prior "fire every pass" semantics — important for in-
|
||||
// repo integration tests and s3tests's sub-minute driver. Positive
|
||||
// values throttle the steady-state walker per shard.
|
||||
if mins := readInt64(adminValues, WalkerIntervalMinutesAdminKey, 0); mins > 0 {
|
||||
cfg.WalkerInterval = time.Duration(mins) * time.Minute
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func readInt64(values map[string]*plugin_pb.ConfigValue, field string, fallback int64) int64 {
|
||||
v, ok := values[field]
|
||||
if !ok || v == nil {
|
||||
return fallback
|
||||
}
|
||||
switch k := v.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return k.Int64Value
|
||||
case *plugin_pb.ConfigValue_DoubleValue:
|
||||
return int64(k.DoubleValue)
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
return fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
95
weed/worker/tasks/s3_lifecycle/config_test.go
Normal file
95
weed/worker/tasks/s3_lifecycle/config_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package s3_lifecycle
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
func TestParseConfigDefaults(t *testing.T) {
|
||||
cfg := ParseConfig(nil, nil)
|
||||
if cfg.Workers != shardPipelineGoroutines {
|
||||
t.Errorf("Workers default=%d, want %d", cfg.Workers, shardPipelineGoroutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigIgnoresWorkerValues(t *testing.T) {
|
||||
// Worker-side config form is currently empty. The old
|
||||
// max_runtime_minutes knob duplicated the admin scheduler's
|
||||
// Execution Timeout and was removed; anything in workerValues is
|
||||
// now silently ignored. The single source of truth for the
|
||||
// per-Execute wall-clock cap is AdminRuntimeDefaults.ExecutionTimeoutSeconds.
|
||||
worker := map[string]*plugin_pb.ConfigValue{
|
||||
"max_runtime_minutes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 120}},
|
||||
}
|
||||
cfg := ParseConfig(nil, worker)
|
||||
if cfg.Workers != shardPipelineGoroutines {
|
||||
t.Errorf("Workers default=%d, want %d", cfg.Workers, shardPipelineGoroutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigMetaLogRetentionDefaultsToZero(t *testing.T) {
|
||||
// Unset key keeps MetaLogRetention at 0, which runShard treats as
|
||||
// "no retention info supplied" and falls back to maxTTL.
|
||||
cfg := ParseConfig(nil, nil)
|
||||
if cfg.MetaLogRetention != 0 {
|
||||
t.Errorf("MetaLogRetention default=%v, want 0", cfg.MetaLogRetention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigMetaLogRetentionDaysConvertsToDuration(t *testing.T) {
|
||||
admin := map[string]*plugin_pb.ConfigValue{
|
||||
MetaLogRetentionDaysAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
|
||||
}
|
||||
cfg := ParseConfig(admin, nil)
|
||||
if want := 7 * 24 * time.Hour; cfg.MetaLogRetention != want {
|
||||
t.Errorf("MetaLogRetention=%v, want %v (7 days)", cfg.MetaLogRetention, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigMetaLogRetentionNegativeStaysZero(t *testing.T) {
|
||||
// A negative declaration is nonsense; stay at 0 so runShard's
|
||||
// fallback applies rather than producing a negative window.
|
||||
admin := map[string]*plugin_pb.ConfigValue{
|
||||
MetaLogRetentionDaysAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: -3}},
|
||||
}
|
||||
cfg := ParseConfig(admin, nil)
|
||||
if cfg.MetaLogRetention != 0 {
|
||||
t.Errorf("negative MetaLogRetention should stay 0, got %v", cfg.MetaLogRetention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWalkerIntervalDefaultsToZero(t *testing.T) {
|
||||
// Unset key keeps WalkerInterval at 0 so dailyrun.runShard fires the
|
||||
// walker every pass (the pre-throttle behavior the s3tests fast
|
||||
// driver and the in-repo integration tests rely on).
|
||||
cfg := ParseConfig(nil, nil)
|
||||
if cfg.WalkerInterval != 0 {
|
||||
t.Errorf("WalkerInterval default=%v, want 0", cfg.WalkerInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWalkerIntervalMinutesConvertsToDuration(t *testing.T) {
|
||||
admin := map[string]*plugin_pb.ConfigValue{
|
||||
WalkerIntervalMinutesAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 90}},
|
||||
}
|
||||
cfg := ParseConfig(admin, nil)
|
||||
if want := 90 * time.Minute; cfg.WalkerInterval != want {
|
||||
t.Errorf("WalkerInterval=%v, want %v", cfg.WalkerInterval, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWalkerIntervalNegativeStaysZero(t *testing.T) {
|
||||
// Negative declarations stay at 0 so the worker keeps "fire every
|
||||
// pass" rather than treating the negative as past-due (which would
|
||||
// fire every pass anyway — but via a less obvious code path that
|
||||
// future readers would have to trace).
|
||||
admin := map[string]*plugin_pb.ConfigValue{
|
||||
WalkerIntervalMinutesAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: -10}},
|
||||
}
|
||||
cfg := ParseConfig(admin, nil)
|
||||
if cfg.WalkerInterval != 0 {
|
||||
t.Errorf("negative WalkerInterval should stay 0, got %v", cfg.WalkerInterval)
|
||||
}
|
||||
}
|
||||
453
weed/worker/tasks/s3_lifecycle/handler.go
Normal file
453
weed/worker/tasks/s3_lifecycle/handler.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package s3_lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dailyrun"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dispatcher"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler"
|
||||
"golang.org/x/time/rate"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
||||
JobType: jobType,
|
||||
Category: pluginworker.CategoryDefault,
|
||||
Aliases: []string{"s3-lifecycle", "s3.lifecycle", "lifecycle"},
|
||||
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
||||
return NewHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handler is the worker-side runner for S3 object lifecycle expiration.
|
||||
// One Execute call drives one bounded dailyrun.Run pass against the S3
|
||||
// endpoints discovered from the master; admin caps concurrency at one
|
||||
// job per worker so a fresh proposal only spawns a new run after the
|
||||
// prior one exits.
|
||||
type Handler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
func NewHandler(grpcDialOption grpc.DialOption) *Handler {
|
||||
return &Handler{grpcDialOption: grpcDialOption}
|
||||
}
|
||||
|
||||
func (h *Handler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: jobType,
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 1,
|
||||
DisplayName: "S3 Lifecycle",
|
||||
Description: "Daily batch: scan the filer meta-log and delete objects whose lifecycle rule has fired.",
|
||||
Weight: 20,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: jobType,
|
||||
DisplayName: "S3 Lifecycle",
|
||||
Description: "Daily S3 object expiration scan with per-shard cursors and bounded retry.",
|
||||
Icon: "fas fa-recycle",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "s3-lifecycle-admin",
|
||||
Title: "S3 Lifecycle",
|
||||
Description: "Cluster-wide delete-throughput cap.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Cluster-wide delete-throughput cap.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: ClusterDeletesPerSecondAdminKey,
|
||||
Label: "Cluster Delete Rate (per second)",
|
||||
Description: "Cluster-wide ceiling on lifecycle delete RPCs per second, divided evenly across active s3_lifecycle workers at job-dispatch time. 0 = unlimited.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: ClusterDeletesBurstAdminKey,
|
||||
Label: "Cluster Delete Burst",
|
||||
Description: "Token-bucket burst capacity across the cluster (max simultaneous deletes). 0 = 2 × rate. Same allocation rule as the rate.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: MetaLogRetentionDaysAdminKey,
|
||||
Label: "Meta-Log Retention (days)",
|
||||
Description: "How far back the filer's meta-log subscription can reach. Rules whose TTL exceeds this run via the walker; shrinking this value will trigger a one-time recovery walk on the next run for any rule that's now too old to replay. 0 = unbounded (no partition; every rule serviced by replay).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: WalkerIntervalMinutesAdminKey,
|
||||
Label: "Walker Interval (minutes)",
|
||||
Description: "Minimum time between steady-state walker fires per shard. Cold-start and rule-change recovery walks ignore this — they run unconditionally. 0 = fire on every run (use when the worker is scheduled at the desired walk cadence, e.g. hourly). Set to a positive value when the worker runs at a tighter cadence than the desired walk frequency, to avoid hammering filer with a full subtree scan per run.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
ClusterDeletesPerSecondAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
ClusterDeletesBurstAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
MetaLogRetentionDaysAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
WalkerIntervalMinutesAdminKey: {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
},
|
||||
// WorkerConfigForm intentionally absent — the prior
|
||||
// "Per-Run Time Limit (minutes)" knob duplicated the admin
|
||||
// scheduler's Execution Timeout (both wall-clock caps on the
|
||||
// same Execute call), and operators had to keep the two values
|
||||
// in agreement. Removed in favor of a single source of truth:
|
||||
// AdminRuntimeDefaults.ExecutionTimeoutSeconds below.
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
// On by default: S3 lifecycle is a standard bucket feature
|
||||
// (PutBucketLifecycleConfiguration is part of the S3 API),
|
||||
// and a bucket with rules set but no worker running silently
|
||||
// retains data past its declared expiration. Operators who
|
||||
// want the worker off can still disable it in the admin UI;
|
||||
// the default error is "data lingers" not "worker burns CPU
|
||||
// on empty rule sets" (the worker fast-exits with no
|
||||
// configured rules).
|
||||
Enabled: true,
|
||||
DetectionIntervalMinutes: 24 * 60, // daily
|
||||
DetectionTimeoutSeconds: 60,
|
||||
MaxJobsPerDetection: 1,
|
||||
// Effectively no per-pass wall-clock cap. Lifecycle is a
|
||||
// scheduled batch — its natural duration is "as long as it
|
||||
// takes to process today's events." The scheduler's global
|
||||
// 90s default would kill every real run, and a numeric
|
||||
// cap operators have to estimate (1h? 8h?) is a recurring
|
||||
// footgun: too low truncates a legitimate large-bucket
|
||||
// pass; too high makes the value meaningless.
|
||||
//
|
||||
// Use math.MaxInt32 seconds (~68 years) for both knobs to
|
||||
// say "no timeout in practice" in code-review-readable form.
|
||||
// Operators who genuinely want a cap can set one in the
|
||||
// admin UI; the underlying context.WithTimeout machinery
|
||||
// is unchanged.
|
||||
ExecutionTimeoutSeconds: math.MaxInt32,
|
||||
JobTypeMaxRuntimeSeconds: math.MaxInt32,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
|
||||
if request == nil || sender == nil {
|
||||
return fmt.Errorf("detect: nil request or sender")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != jobType {
|
||||
return fmt.Errorf("job type %q is not handled by s3 lifecycle handler", request.JobType)
|
||||
}
|
||||
s3Endpoints := clusterS3Endpoints(request.ClusterContext)
|
||||
if len(s3Endpoints) == 0 {
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no s3 servers registered with master", nil))
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{JobType: jobType, Success: true})
|
||||
}
|
||||
filerAddresses := []string{}
|
||||
if request.ClusterContext != nil {
|
||||
filerAddresses = append(filerAddresses, request.ClusterContext.FilerAddresses...)
|
||||
}
|
||||
if len(filerAddresses) == 0 {
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no filer addresses in cluster context", nil))
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{JobType: jobType, Success: true})
|
||||
}
|
||||
|
||||
// FilerAddresses are pb.ServerAddress strings (host:httpPort.grpcPort);
|
||||
// Execute dials filer_grpc_address verbatim, so resolve it to a gRPC address.
|
||||
filerGrpcAddress := pb.ServerAddress(filerAddresses[0]).ToGrpcAddress()
|
||||
proposal := &plugin_pb.JobProposal{
|
||||
JobType: jobType,
|
||||
ProposalId: fmt.Sprintf("s3-lifecycle-%d", time.Now().UnixNano()),
|
||||
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"filer_grpc_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: filerGrpcAddress}},
|
||||
},
|
||||
}
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: jobType,
|
||||
Proposals: []*plugin_pb.JobProposal{proposal},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: jobType,
|
||||
Success: true,
|
||||
TotalProposals: 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
|
||||
if request == nil || request.Job == nil || sender == nil {
|
||||
return fmt.Errorf("execute: nil request/job/sender")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != jobType {
|
||||
return fmt.Errorf("job type %q is not handled by s3 lifecycle handler", request.Job.JobType)
|
||||
}
|
||||
cfg := ParseConfig(request.GetAdminConfigValues(), request.GetWorkerConfigValues())
|
||||
s3Endpoints := clusterS3Endpoints(request.ClusterContext)
|
||||
if len(s3Endpoints) == 0 {
|
||||
return fmt.Errorf("execute: no s3 servers registered with master")
|
||||
}
|
||||
filerAddress := readString(request.Job.Parameters, "filer_grpc_address", "")
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("execute: missing filer_grpc_address in job parameters")
|
||||
}
|
||||
|
||||
// Run lifetime is bounded by the scheduler's Execution Timeout
|
||||
// (admin UI). The handler used to wrap ctx in another
|
||||
// context.WithTimeout(cfg.MaxRuntime), but that doubled the
|
||||
// concept — both knobs were wall-clock caps on the same Execute
|
||||
// call, and the smaller one always won (typically the 90s
|
||||
// scheduler default would clobber a 60-min worker setting).
|
||||
// Single source of truth is now AdminRuntimeDefaults.ExecutionTimeoutSeconds.
|
||||
runCtx := ctx
|
||||
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId, JobType: jobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING, Stage: "starting",
|
||||
Message: fmt.Sprintf("scheduler workers=%d s3=%v", cfg.Workers, s3Endpoints),
|
||||
})
|
||||
|
||||
dialCtx, dialCancel := context.WithTimeout(runCtx, 30*time.Second)
|
||||
filerConn, err := pb.GrpcDial(dialCtx, filerAddress, false, h.grpcDialOption)
|
||||
dialCancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial filer %s: %w", filerAddress, err)
|
||||
}
|
||||
defer filerConn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(filerConn)
|
||||
|
||||
bucketsPath, err := lookupBucketsPath(runCtx, filerClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("buckets path: %w", err)
|
||||
}
|
||||
|
||||
dialCtx, dialCancel = context.WithTimeout(runCtx, 30*time.Second)
|
||||
s3Conn, err := pb.GrpcDial(dialCtx, s3Endpoints[0], false, h.grpcDialOption)
|
||||
dialCancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial s3 %s: %w", s3Endpoints[0], err)
|
||||
}
|
||||
defer s3Conn.Close()
|
||||
rpc := s3_lifecycle_pb.NewSeaweedS3LifecycleInternalClient(s3Conn)
|
||||
|
||||
if err := h.executeDailyReplay(runCtx, request, bucketsPath, filerClient, rpc, cfg, sender); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sendSuccessCompletion(request, sender)
|
||||
}
|
||||
|
||||
const dailyReplaySuccessSummary = "s3 lifecycle daily replay completed"
|
||||
|
||||
// JobCompleted must set JobType to the handler's jobType constant;
|
||||
// routed requests may leave request.Job.JobType empty, and the admin
|
||||
// ignores completions with empty JobType.
|
||||
func sendSuccessCompletion(request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: jobType,
|
||||
Success: true,
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: dailyReplaySuccessSummary,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeDailyReplay runs one bounded daily-replay pass via
|
||||
// dailyrun.Run. The walker fires inside runShard on rule-content edits
|
||||
// and against the steady-state walk view; all rule kinds are serviced.
|
||||
func (h *Handler) executeDailyReplay(ctx context.Context, request *plugin_pb.ExecuteJobRequest, bucketsPath string, filerClient filer_pb.SeaweedFilerClient, rpc s3_lifecycle_pb.SeaweedS3LifecycleInternalClient, cfg Config, sender pluginworker.ExecutionSender) error {
|
||||
eng := engine.New()
|
||||
inputs, parseErrors, err := scheduler.LoadCompileInputs(ctx, filerClient, bucketsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daily_replay: load lifecycle inputs: %w", err)
|
||||
}
|
||||
for _, pe := range parseErrors {
|
||||
glog.V(1).Infof("daily_replay: %s: %v", pe.Bucket, pe.Err)
|
||||
}
|
||||
eng.Compile(inputs, engine.CompileOptions{PriorStates: scheduler.AllActivePriorStates(inputs)})
|
||||
|
||||
shards := make([]int, 0, s3lifecycle.ShardCount)
|
||||
for i := 0; i < s3lifecycle.ShardCount; i++ {
|
||||
shards = append(shards, i)
|
||||
}
|
||||
|
||||
limiter, limiterDesc := buildLimiterFromClusterContext(request.GetClusterContext())
|
||||
|
||||
// Reuse one LifecycleClient across the replay drain and the
|
||||
// walker's per-entry dispatch.
|
||||
client := lifecycleRPCAdapter{c: rpc}
|
||||
|
||||
// Bucket list for the walker — derived from inputs so it matches
|
||||
// the snapshot the engine compiled.
|
||||
buckets := make([]string, 0, len(inputs))
|
||||
for _, in := range inputs {
|
||||
if in.Bucket != "" {
|
||||
buckets = append(buckets, in.Bucket)
|
||||
}
|
||||
}
|
||||
walkerListFn := dailyrun.FilerListFunc(filerClient, bucketsPath)
|
||||
// Share the limiter with processMatches so walker + replay can't
|
||||
// combine to burst past the cluster cap.
|
||||
walkerDispatch := &dailyrun.WalkerDispatcher{Client: client, Limiter: limiter}
|
||||
walker := dailyrun.WalkerFunc(func(walkCtx context.Context, view *engine.Snapshot, shardID int) error {
|
||||
return dailyrun.WalkBuckets(walkCtx, view, shardID, buckets, walkerListFn, walkerDispatch)
|
||||
})
|
||||
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId, JobType: jobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING, Stage: "starting",
|
||||
Message: fmt.Sprintf("daily_replay shards=%d workers=%d rate=%s buckets=%d walker=on",
|
||||
len(shards), cfg.Workers, limiterDesc, len(buckets)),
|
||||
})
|
||||
|
||||
runErr := dailyrun.Run(ctx, dailyrun.Config{
|
||||
Shards: shards,
|
||||
BucketsPath: bucketsPath,
|
||||
Engine: eng,
|
||||
FilerClient: filerClient,
|
||||
Client: client,
|
||||
Persister: &dailyrun.FilerCursorPersister{Store: dispatcher.NewFilerStoreClient(filerClient)},
|
||||
Lister: dispatcher.NewFilerSiblingLister(filerClient, bucketsPath),
|
||||
Workers: cfg.Workers,
|
||||
Limiter: limiter,
|
||||
RetentionWindow: cfg.MetaLogRetention,
|
||||
Walker: walker,
|
||||
WalkerInterval: cfg.WalkerInterval,
|
||||
ClientName: "worker-s3-lifecycle-daily",
|
||||
})
|
||||
if runErr != nil {
|
||||
glog.Warningf("daily_replay: %v", runErr)
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLimiterFromClusterContext parses the per-worker share the admin
|
||||
// wrote into ClusterContext.Metadata (see weed/admin/plugin/plugin.go's
|
||||
// s3_lifecycle injection) and returns a rate.Limiter, or nil when no
|
||||
// rate cap applies. The description string is for the JobProgressUpdate
|
||||
// so operators can see "rate=unlimited" / "rate=12.5/s burst=25" in
|
||||
// the activity log.
|
||||
//
|
||||
// Tolerant of missing keys, empty strings, malformed numbers, and
|
||||
// non-positive values — all treated as "no limit" rather than failing
|
||||
// the run. The admin allocator is the single point that decides whether
|
||||
// to populate these keys; the worker doesn't second-guess.
|
||||
func buildLimiterFromClusterContext(cc *plugin_pb.ClusterContext) (*rate.Limiter, string) {
|
||||
if cc == nil || cc.Metadata == nil {
|
||||
return nil, "unlimited"
|
||||
}
|
||||
rps, ok := parsePositiveFloat(cc.Metadata[MetadataKeyDeletesPerSecond])
|
||||
if !ok {
|
||||
return nil, "unlimited"
|
||||
}
|
||||
burst, _ := parsePositiveInt(cc.Metadata[MetadataKeyDeletesBurst])
|
||||
if burst <= 0 {
|
||||
// Sensible default: enough headroom for one tick's worth of
|
||||
// throughput. Caller may also supply 0 to opt into this default.
|
||||
burst = int(rps * 2)
|
||||
if burst < 1 {
|
||||
burst = 1
|
||||
}
|
||||
}
|
||||
return rate.NewLimiter(rate.Limit(rps), burst), fmt.Sprintf("%.3g/s burst=%d", rps, burst)
|
||||
}
|
||||
|
||||
func parsePositiveFloat(s string) (float64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// clusterS3Endpoints returns the master-discovered S3 gRPC addresses for the
|
||||
// cluster. The handler dials the first reachable one; the master refreshes
|
||||
// the list on KeepConnected so a stale entry self-heals on the next run.
|
||||
func clusterS3Endpoints(cc *plugin_pb.ClusterContext) []string {
|
||||
if cc == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(cc.S3GrpcAddresses))
|
||||
for _, addr := range cc.S3GrpcAddresses {
|
||||
if addr != "" {
|
||||
out = append(out, addr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type lifecycleRPCAdapter struct {
|
||||
c s3_lifecycle_pb.SeaweedS3LifecycleInternalClient
|
||||
}
|
||||
|
||||
func (a lifecycleRPCAdapter) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
||||
ctx, _ = credential.WithS3InternalAdminAuth(ctx)
|
||||
return a.c.LifecycleDelete(ctx, req)
|
||||
}
|
||||
|
||||
func lookupBucketsPath(ctx context.Context, client filer_pb.SeaweedFilerClient) (string, error) {
|
||||
resp, err := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path := resp.GetDirBuckets(); path != "" {
|
||||
return path, nil
|
||||
}
|
||||
return "/buckets", nil
|
||||
}
|
||||
|
||||
func readString(values map[string]*plugin_pb.ConfigValue, field, fallback string) string {
|
||||
v, ok := values[field]
|
||||
if !ok || v == nil {
|
||||
return fallback
|
||||
}
|
||||
if k, ok := v.Kind.(*plugin_pb.ConfigValue_StringValue); ok {
|
||||
return k.StringValue
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
var _ pluginworker.JobHandler = (*Handler)(nil)
|
||||
550
weed/worker/tasks/s3_lifecycle/handler_test.go
Normal file
550
weed/worker/tasks/s3_lifecycle/handler_test.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package s3_lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Tests cover the worker-handler surface that runs without a live filer
|
||||
// or S3 server: pure helpers (clusterS3Endpoints, readString),
|
||||
// Capability/Descriptor sanity, and the Detect input-validation /
|
||||
// skip-activity / proposal paths driven by a recorder sender.
|
||||
|
||||
// recordingSender captures everything the handler sends so each Detect
|
||||
// case can assert on activities, proposals, and completion.
|
||||
type recordingSender struct {
|
||||
proposals []*plugin_pb.DetectionProposals
|
||||
completes []*plugin_pb.DetectionComplete
|
||||
activities []*plugin_pb.ActivityEvent
|
||||
// errOn forces the named send to fail; lets tests cover the
|
||||
// SendComplete error-propagation path without a full transport stub.
|
||||
errOn map[string]error
|
||||
}
|
||||
|
||||
func (r *recordingSender) SendProposals(p *plugin_pb.DetectionProposals) error {
|
||||
if err := r.errOn["proposals"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.proposals = append(r.proposals, p)
|
||||
return nil
|
||||
}
|
||||
func (r *recordingSender) SendComplete(c *plugin_pb.DetectionComplete) error {
|
||||
if err := r.errOn["complete"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.completes = append(r.completes, c)
|
||||
return nil
|
||||
}
|
||||
func (r *recordingSender) SendActivity(a *plugin_pb.ActivityEvent) error {
|
||||
if err := r.errOn["activity"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activities = append(r.activities, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- clusterS3Endpoints ----------
|
||||
|
||||
func TestClusterS3Endpoints_NilContext(t *testing.T) {
|
||||
// A nil ClusterContext occurs in early-bootstrap detect calls; the
|
||||
// handler must return an empty slice rather than panic.
|
||||
assert.Nil(t, clusterS3Endpoints(nil))
|
||||
}
|
||||
|
||||
func TestClusterS3Endpoints_EmptyList(t *testing.T) {
|
||||
assert.Empty(t, clusterS3Endpoints(&plugin_pb.ClusterContext{}))
|
||||
}
|
||||
|
||||
func TestClusterS3Endpoints_FiltersEmptyEntries(t *testing.T) {
|
||||
// An empty-string address represents a stale registry entry the
|
||||
// master is about to evict; never dial that. Order of the surviving
|
||||
// entries must be preserved so the handler dials a deterministic
|
||||
// host across detect runs.
|
||||
cc := &plugin_pb.ClusterContext{S3GrpcAddresses: []string{"s3a:8333", "", "s3b:8333", ""}}
|
||||
assert.Equal(t, []string{"s3a:8333", "s3b:8333"}, clusterS3Endpoints(cc))
|
||||
}
|
||||
|
||||
func TestClusterS3Endpoints_AllValid(t *testing.T) {
|
||||
cc := &plugin_pb.ClusterContext{S3GrpcAddresses: []string{"s3a:8333", "s3b:8333"}}
|
||||
assert.Equal(t, []string{"s3a:8333", "s3b:8333"}, clusterS3Endpoints(cc))
|
||||
}
|
||||
|
||||
// ---------- readString ----------
|
||||
|
||||
func TestReadString_MissingKeyReturnsFallback(t *testing.T) {
|
||||
got := readString(map[string]*plugin_pb.ConfigValue{}, "missing", "fallback")
|
||||
assert.Equal(t, "fallback", got)
|
||||
}
|
||||
|
||||
func TestReadString_NilValueReturnsFallback(t *testing.T) {
|
||||
got := readString(map[string]*plugin_pb.ConfigValue{"k": nil}, "k", "fallback")
|
||||
assert.Equal(t, "fallback", got)
|
||||
}
|
||||
|
||||
func TestReadString_WrongKindReturnsFallback(t *testing.T) {
|
||||
// Configs are typed; an Int64 in a string slot is a writer-side bug
|
||||
// the handler must tolerate rather than panic on a type assertion.
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"k": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 42}},
|
||||
}
|
||||
assert.Equal(t, "fallback", readString(values, "k", "fallback"))
|
||||
}
|
||||
|
||||
func TestReadString_StringValueReturned(t *testing.T) {
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"k": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "expected"}},
|
||||
}
|
||||
assert.Equal(t, "expected", readString(values, "k", "fallback"))
|
||||
}
|
||||
|
||||
// ---------- Capability ----------
|
||||
|
||||
func TestCapability_AdvertisesJobType(t *testing.T) {
|
||||
h := NewHandler(nil)
|
||||
cap := h.Capability()
|
||||
require.NotNil(t, cap)
|
||||
assert.Equal(t, jobType, cap.JobType)
|
||||
assert.True(t, cap.CanDetect)
|
||||
assert.True(t, cap.CanExecute)
|
||||
// Admin caps concurrency at one job per worker; a rebuild of the
|
||||
// scheduler must wait for the prior one to exit.
|
||||
assert.Equal(t, int32(1), cap.MaxDetectionConcurrency)
|
||||
assert.Equal(t, int32(1), cap.MaxExecutionConcurrency)
|
||||
}
|
||||
|
||||
// ---------- Detect ----------
|
||||
|
||||
func TestDetect_NilRequestErrors(t *testing.T) {
|
||||
h := NewHandler(nil)
|
||||
r := &recordingSender{}
|
||||
err := h.Detect(context.Background(), nil, r)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, r.proposals)
|
||||
assert.Empty(t, r.completes)
|
||||
}
|
||||
|
||||
func TestDetect_NilSenderErrors(t *testing.T) {
|
||||
h := NewHandler(nil)
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{}, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDetect_WrongJobTypeErrors(t *testing.T) {
|
||||
// A request routed to this handler with a foreign JobType is the
|
||||
// admin's bug; surface as an error so it's visible rather than
|
||||
// silently emitting a bogus proposal.
|
||||
h := NewHandler(nil)
|
||||
r := &recordingSender{}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{JobType: "different_job"}, r)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "different_job")
|
||||
}
|
||||
|
||||
func TestDetect_NoS3EndpointsCompletesWithSkipActivity(t *testing.T) {
|
||||
// A cluster with no S3 servers registered yet must not spawn the
|
||||
// scheduler; emit a "skipped" activity for operator visibility and
|
||||
// complete with success so the admin doesn't classify as a failure.
|
||||
h := NewHandler(nil)
|
||||
r := &recordingSender{}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: jobType,
|
||||
ClusterContext: &plugin_pb.ClusterContext{},
|
||||
}, r)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, r.proposals, "no S3 endpoints must not yield a proposal")
|
||||
require.Len(t, r.activities, 1)
|
||||
assert.Equal(t, "skipped", r.activities[0].Stage)
|
||||
assert.Contains(t, r.activities[0].Message, "no s3 servers")
|
||||
require.Len(t, r.completes, 1)
|
||||
assert.True(t, r.completes[0].Success, "skip is success, not failure")
|
||||
assert.Equal(t, jobType, r.completes[0].JobType)
|
||||
}
|
||||
|
||||
func TestDetect_NoFilerAddressesCompletesWithSkipActivity(t *testing.T) {
|
||||
h := NewHandler(nil)
|
||||
r := &recordingSender{}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: jobType,
|
||||
ClusterContext: &plugin_pb.ClusterContext{
|
||||
S3GrpcAddresses: []string{"s3a:8333"},
|
||||
// no FilerAddresses
|
||||
},
|
||||
}, r)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, r.proposals)
|
||||
require.Len(t, r.activities, 1)
|
||||
assert.Equal(t, "skipped", r.activities[0].Stage)
|
||||
assert.Contains(t, r.activities[0].Message, "no filer addresses")
|
||||
require.Len(t, r.completes, 1)
|
||||
assert.True(t, r.completes[0].Success)
|
||||
}
|
||||
|
||||
func TestDetect_HappyPathProposesOneJobWithFirstFilerAddress(t *testing.T) {
|
||||
// Detect must propose exactly one job that targets the first filer
|
||||
// address in the cluster context; the master refreshes the list so
|
||||
// a stale entry self-heals on the next run.
|
||||
h := NewHandler(nil)
|
||||
r := &recordingSender{}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: jobType,
|
||||
ClusterContext: &plugin_pb.ClusterContext{
|
||||
S3GrpcAddresses: []string{"s3a:8333"},
|
||||
// pb.ServerAddress dual-port form; the gRPC port is pinned off the
|
||||
// +10000 convention so a raw-forwarding regression resurfaces here.
|
||||
FilerAddresses: []string{"filer-a:8888.18890", "filer-b:8888.18890"},
|
||||
},
|
||||
}, r)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, r.activities, "happy path emits no skip activity")
|
||||
|
||||
require.Len(t, r.proposals, 1)
|
||||
require.Len(t, r.proposals[0].Proposals, 1)
|
||||
prop := r.proposals[0].Proposals[0]
|
||||
assert.Equal(t, jobType, prop.JobType)
|
||||
assert.NotEmpty(t, prop.ProposalId, "proposal id must be unique-per-run")
|
||||
require.Contains(t, prop.Parameters, "filer_grpc_address")
|
||||
val := prop.Parameters["filer_grpc_address"].GetStringValue()
|
||||
assert.Equal(t, "filer-a:18890", val, "first filer resolved to its gRPC port, not host:28890")
|
||||
|
||||
require.Len(t, r.completes, 1)
|
||||
assert.True(t, r.completes[0].Success)
|
||||
assert.Equal(t, int32(1), r.completes[0].TotalProposals)
|
||||
}
|
||||
|
||||
func TestDetect_EmptyJobTypeAccepted(t *testing.T) {
|
||||
// Detect is sometimes invoked with an unset JobType (broadcast
|
||||
// detect); the handler must accept and behave as if it matched.
|
||||
h := NewHandler(nil)
|
||||
r := &recordingSender{}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
ClusterContext: &plugin_pb.ClusterContext{
|
||||
S3GrpcAddresses: []string{"s3a:8333"},
|
||||
FilerAddresses: []string{"f:8888.18890"},
|
||||
},
|
||||
}, r)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.proposals, 1)
|
||||
}
|
||||
|
||||
func TestDetect_PropagatesProposalsSendError(t *testing.T) {
|
||||
// SendProposals failing must propagate; otherwise the worker would
|
||||
// silently report success despite never delivering the proposal.
|
||||
h := NewHandler(nil)
|
||||
want := errors.New("transport down")
|
||||
r := &recordingSender{errOn: map[string]error{"proposals": want}}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: jobType,
|
||||
ClusterContext: &plugin_pb.ClusterContext{
|
||||
S3GrpcAddresses: []string{"s3a:8333"},
|
||||
FilerAddresses: []string{"f:8888.18890"},
|
||||
},
|
||||
}, r)
|
||||
assert.ErrorIs(t, err, want)
|
||||
assert.Empty(t, r.proposals)
|
||||
assert.Empty(t, r.completes, "complete must not fire when proposals fail")
|
||||
}
|
||||
|
||||
func TestDetect_PropagatesCompleteSendError(t *testing.T) {
|
||||
// SendComplete failing must propagate; otherwise the worker would
|
||||
// report success to the admin despite the completion signal never
|
||||
// landing. Proposals went out before the failure, so they remain in
|
||||
// the recorder.
|
||||
h := NewHandler(nil)
|
||||
want := errors.New("transport down")
|
||||
r := &recordingSender{errOn: map[string]error{"complete": want}}
|
||||
err := h.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: jobType,
|
||||
ClusterContext: &plugin_pb.ClusterContext{
|
||||
S3GrpcAddresses: []string{"s3a:8333"},
|
||||
FilerAddresses: []string{"f:8888.18890"},
|
||||
},
|
||||
}, r)
|
||||
assert.ErrorIs(t, err, want)
|
||||
assert.Len(t, r.proposals, 1, "proposals send before complete and remain recorded")
|
||||
assert.Empty(t, r.completes)
|
||||
}
|
||||
|
||||
// ---------- Descriptor ----------
|
||||
|
||||
func TestDescriptor_BasicShape(t *testing.T) {
|
||||
// Sanity-check the Descriptor's public-facing identifiers so a
|
||||
// rename in handler.go doesn't silently break the admin UI without
|
||||
// an admin-side change too.
|
||||
h := NewHandler(nil)
|
||||
d := h.Descriptor()
|
||||
require.NotNil(t, d)
|
||||
assert.Equal(t, jobType, d.JobType)
|
||||
assert.NotEmpty(t, d.DisplayName)
|
||||
assert.NotEmpty(t, d.Description)
|
||||
assert.Greater(t, d.DescriptorVersion, uint32(0), "descriptor version must be positive (admins use it for compat)")
|
||||
}
|
||||
|
||||
func TestDescriptor_AdminConfigFormHasNoWorkersField(t *testing.T) {
|
||||
// "workers" used to be an admin form field controlling in-process
|
||||
// pipeline goroutines. It's per-worker tuning, not a cluster-wide
|
||||
// scope concern, so it was removed from the form. ParseConfig hard-
|
||||
// codes cfg.Workers from shardPipelineGoroutines instead.
|
||||
h := NewHandler(nil)
|
||||
d := h.Descriptor()
|
||||
require.NotNil(t, d.AdminConfigForm)
|
||||
for _, sec := range d.AdminConfigForm.Sections {
|
||||
for _, f := range sec.Fields {
|
||||
assert.NotEqual(t, "workers", f.Name, "admin form must NOT expose the in-memory pipeline-goroutine knob")
|
||||
}
|
||||
}
|
||||
_, hasDefault := d.AdminConfigForm.DefaultValues["workers"]
|
||||
assert.False(t, hasDefault, "DefaultValues must NOT include 'workers' (form field removed)")
|
||||
}
|
||||
|
||||
func TestDescriptor_WorkerConfigFormIsAbsent(t *testing.T) {
|
||||
// "Per-Run Time Limit (minutes)" was the only worker-side knob and
|
||||
// duplicated the admin scheduler's Execution Timeout (both are
|
||||
// wall-clock caps on the same Execute call). Removed in favor of
|
||||
// AdminRuntimeDefaults.ExecutionTimeoutSeconds — single source of
|
||||
// truth. A WorkerConfigForm with no fields would render as an
|
||||
// empty section in the admin UI; drop the form entirely.
|
||||
h := NewHandler(nil)
|
||||
d := h.Descriptor()
|
||||
assert.Nil(t, d.WorkerConfigForm,
|
||||
"WorkerConfigForm should be nil now that max_runtime_minutes is gone; if you re-add a worker-side knob, restore the form and pin it here")
|
||||
}
|
||||
|
||||
func TestDescriptor_AdminRuntimeDefaultsHaveNoTimeoutInPractice(t *testing.T) {
|
||||
// Lifecycle is a scheduled batch whose natural duration is "as
|
||||
// long as today's events take." The scheduler's global 90s default
|
||||
// would kill every real run, and a numeric cap operators have to
|
||||
// estimate is a footgun (too low truncates a legitimate
|
||||
// large-bucket pass; too high makes the value meaningless). Declare
|
||||
// math.MaxInt32 seconds for both knobs to say "no timeout in
|
||||
// practice" in a code-review-readable way. A future change that
|
||||
// tightens the cap should fail this test so the choice is
|
||||
// re-examined consciously.
|
||||
h := NewHandler(nil)
|
||||
d := h.Descriptor()
|
||||
require.NotNil(t, d.AdminRuntimeDefaults)
|
||||
assert.Equal(t, int32(math.MaxInt32), d.AdminRuntimeDefaults.ExecutionTimeoutSeconds,
|
||||
"ExecutionTimeoutSeconds should be effectively unlimited; the scheduler's 90s default would otherwise clobber the worker mid-run")
|
||||
assert.Equal(t, int32(math.MaxInt32), d.AdminRuntimeDefaults.JobTypeMaxRuntimeSeconds,
|
||||
"JobTypeMaxRuntimeSeconds is the per-pass budget — keep aligned with ExecutionTimeoutSeconds")
|
||||
}
|
||||
|
||||
func TestDescriptor_AdminRuntimeDefaultsDailyCadence(t *testing.T) {
|
||||
// Lifecycle is a daily batch; the admin must default to a 24-hour
|
||||
// detection interval so cron pressure doesn't escalate. Bound the
|
||||
// detection timeout so a stuck detect can't pin a worker slot
|
||||
// indefinitely. Max 1 job per detection = scheduler runs alone.
|
||||
h := NewHandler(nil)
|
||||
d := h.Descriptor()
|
||||
require.NotNil(t, d.AdminRuntimeDefaults)
|
||||
assert.Equal(t, int32(24*60), d.AdminRuntimeDefaults.DetectionIntervalMinutes)
|
||||
assert.Equal(t, int32(60), d.AdminRuntimeDefaults.DetectionTimeoutSeconds, "60s timeout caps a stuck detect at one minute")
|
||||
assert.Equal(t, int32(1), d.AdminRuntimeDefaults.MaxJobsPerDetection)
|
||||
}
|
||||
|
||||
func TestDescriptor_AdminRuntimeDefaultsEnabledByDefault(t *testing.T) {
|
||||
// S3 lifecycle is a standard bucket feature — operators set
|
||||
// PutBucketLifecycleConfiguration expecting it to actually fire.
|
||||
// Default-off would silently retain data past declared expiration
|
||||
// until an operator notices and flips it on. Document the choice
|
||||
// here so a future change to disabled-by-default fails the test
|
||||
// and surfaces a conscious revisit.
|
||||
h := NewHandler(nil)
|
||||
d := h.Descriptor()
|
||||
require.NotNil(t, d.AdminRuntimeDefaults)
|
||||
assert.True(t, d.AdminRuntimeDefaults.Enabled,
|
||||
"s3_lifecycle defaults must enable the scheduler so configured rules fire without operator opt-in")
|
||||
}
|
||||
|
||||
// ---------- Execute ----------
|
||||
|
||||
// recordingExecSender captures Execute-side messages. The Execute path
|
||||
// dials gRPC after passing validation, so these tests only exercise
|
||||
// the validation surface that errors out before any dial — proving
|
||||
// the handler refuses malformed jobs early instead of waiting on a
|
||||
// 30s dial timeout.
|
||||
type recordingExecSender struct {
|
||||
progress []*plugin_pb.JobProgressUpdate
|
||||
completed []*plugin_pb.JobCompleted
|
||||
completedErr error
|
||||
}
|
||||
|
||||
func (r *recordingExecSender) SendProgress(p *plugin_pb.JobProgressUpdate) error {
|
||||
r.progress = append(r.progress, p)
|
||||
return nil
|
||||
}
|
||||
func (r *recordingExecSender) SendCompleted(c *plugin_pb.JobCompleted) error {
|
||||
if r.completedErr != nil {
|
||||
return r.completedErr
|
||||
}
|
||||
r.completed = append(r.completed, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExecute_NilRequestErrors(t *testing.T) {
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), nil, &recordingExecSender{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil")
|
||||
}
|
||||
|
||||
func TestExecute_NilJobErrors(t *testing.T) {
|
||||
// A non-nil request with nil Job is a writer-side bug; refuse it
|
||||
// rather than panic dereferencing request.Job.JobType.
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{}, &recordingExecSender{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil")
|
||||
}
|
||||
|
||||
func TestExecute_NilSenderErrors(t *testing.T) {
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobType: jobType},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil")
|
||||
}
|
||||
|
||||
func TestExecute_WrongJobTypeErrors(t *testing.T) {
|
||||
// A foreign job type routed to this handler is the admin's bug;
|
||||
// surface as an error rather than running a bogus scheduler.
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobType: "different_job"},
|
||||
}, &recordingExecSender{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "different_job")
|
||||
}
|
||||
|
||||
func TestExecute_NoS3EndpointsErrors(t *testing.T) {
|
||||
// Detect emits a "skipped" activity for this case; Execute is
|
||||
// stricter — the admin shouldn't have routed an Execute request
|
||||
// without S3 endpoints, so error out instead of silently no-oping.
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobType: jobType},
|
||||
ClusterContext: &plugin_pb.ClusterContext{}, // no S3GrpcAddresses
|
||||
}, &recordingExecSender{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no s3 servers")
|
||||
}
|
||||
|
||||
func TestExecute_MissingFilerAddressErrors(t *testing.T) {
|
||||
// filer_grpc_address is set by Detect when it builds the proposal;
|
||||
// missing it means the proposal was tampered with or the admin
|
||||
// dropped the parameter. Refuse rather than dial nothing.
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{
|
||||
JobType: jobType,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{}, // no filer_grpc_address
|
||||
},
|
||||
ClusterContext: &plugin_pb.ClusterContext{
|
||||
S3GrpcAddresses: []string{"s3a:8333"},
|
||||
},
|
||||
}, &recordingExecSender{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "filer_grpc_address")
|
||||
}
|
||||
|
||||
func TestExecute_EmptyJobTypeAccepted(t *testing.T) {
|
||||
// Same convention as Detect: an empty JobType is broadcast routing
|
||||
// and must be accepted. The handler then errors at the next
|
||||
// validation step (no S3 endpoints) rather than at the type check.
|
||||
h := NewHandler(nil)
|
||||
err := h.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{}, // empty JobType
|
||||
ClusterContext: &plugin_pb.ClusterContext{},
|
||||
}, &recordingExecSender{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no s3 servers", "validation flowed past the type check")
|
||||
}
|
||||
|
||||
// ---------- sendSuccessCompletion ----------
|
||||
|
||||
func TestSendSuccessCompletion_EmitsCanonicalCompletion(t *testing.T) {
|
||||
sender := &recordingExecSender{}
|
||||
request := &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobId: "job-123"}, // empty JobType (broadcast routing)
|
||||
}
|
||||
|
||||
require.NoError(t, sendSuccessCompletion(request, sender))
|
||||
|
||||
require.Len(t, sender.completed, 1, "exactly one completion must be sent")
|
||||
c := sender.completed[0]
|
||||
assert.Equal(t, jobType, c.JobType, "completion must use the canonical jobType, not request.Job.JobType")
|
||||
assert.NotEmpty(t, c.JobType, "an empty JobType is dropped by the admin")
|
||||
assert.Equal(t, "job-123", c.JobId, "completion must echo the original JobId")
|
||||
assert.True(t, c.Success, "a clean replay is a success completion")
|
||||
require.NotNil(t, c.Result)
|
||||
assert.NotEmpty(t, c.Result.Summary, "admin UI surfaces a non-empty Result.Summary")
|
||||
}
|
||||
|
||||
func TestSendSuccessCompletion_PropagatesSendError(t *testing.T) {
|
||||
sentinel := errors.New("stream closed")
|
||||
sender := &recordingExecSender{completedErr: sentinel}
|
||||
request := &plugin_pb.ExecuteJobRequest{Job: &plugin_pb.JobSpec{JobId: "job-456"}}
|
||||
|
||||
err := sendSuccessCompletion(request, sender)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
||||
// ---------- lookupBucketsPath ----------
|
||||
|
||||
// stubFilerConfigClient implements filer_pb.SeaweedFilerClient for the
|
||||
// GetFilerConfiguration call lookupBucketsPath relies on; methods that
|
||||
// aren't named here would panic if called, which the tests rely on to
|
||||
// keep the surface narrow.
|
||||
type stubFilerConfigClient struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
resp *filer_pb.GetFilerConfigurationResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *stubFilerConfigClient) GetFilerConfiguration(_ context.Context, _ *filer_pb.GetFilerConfigurationRequest, _ ...grpc.CallOption) (*filer_pb.GetFilerConfigurationResponse, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return c.resp, nil
|
||||
}
|
||||
|
||||
func TestLookupBucketsPath_PropagatesGRPCError(t *testing.T) {
|
||||
// A failed config lookup must surface; without it, Execute would
|
||||
// proceed to dial S3 with an empty buckets path and quietly never
|
||||
// dispatch anything.
|
||||
want := errors.New("filer down")
|
||||
got, err := lookupBucketsPath(context.Background(), &stubFilerConfigClient{err: want})
|
||||
assert.ErrorIs(t, err, want)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestLookupBucketsPath_UsesConfiguredDirBuckets(t *testing.T) {
|
||||
// When the filer reports a non-empty DirBuckets, the worker honors
|
||||
// it. Operators with a non-default layout (e.g. "/data/buckets")
|
||||
// can't be routed to "/buckets".
|
||||
got, err := lookupBucketsPath(context.Background(), &stubFilerConfigClient{
|
||||
resp: &filer_pb.GetFilerConfigurationResponse{DirBuckets: "/data/buckets"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/data/buckets", got)
|
||||
}
|
||||
|
||||
func TestLookupBucketsPath_EmptyDirBucketsFallsBackToDefault(t *testing.T) {
|
||||
// Filer doesn't always populate DirBuckets (older configs); the
|
||||
// helper falls back to the documented default "/buckets" rather
|
||||
// than returning an empty path that would force-route to root.
|
||||
got, err := lookupBucketsPath(context.Background(), &stubFilerConfigClient{
|
||||
resp: &filer_pb.GetFilerConfigurationResponse{DirBuckets: ""},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/buckets", got)
|
||||
}
|
||||
38
weed/worker/tasks/schema_provider.go
Normal file
38
weed/worker/tasks/schema_provider.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
)
|
||||
|
||||
// TaskConfigSchema defines the schema for task configuration
|
||||
type TaskConfigSchema struct {
|
||||
config.Schema // Embed common schema functionality
|
||||
TaskName string `json:"task_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// TaskConfigSchemaProvider is an interface for providing task configuration schemas
|
||||
type TaskConfigSchemaProvider interface {
|
||||
GetConfigSchema() *TaskConfigSchema
|
||||
}
|
||||
|
||||
// schemaRegistry maintains a registry of schema providers by task type
|
||||
type schemaRegistry struct {
|
||||
providers map[string]TaskConfigSchemaProvider
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var globalSchemaRegistry = &schemaRegistry{
|
||||
providers: make(map[string]TaskConfigSchemaProvider),
|
||||
}
|
||||
|
||||
// RegisterTaskConfigSchema registers a schema provider for a task type
|
||||
func RegisterTaskConfigSchema(taskType string, provider TaskConfigSchemaProvider) {
|
||||
globalSchemaRegistry.mutex.Lock()
|
||||
defer globalSchemaRegistry.mutex.Unlock()
|
||||
globalSchemaRegistry.providers[taskType] = provider
|
||||
}
|
||||
51
weed/worker/tasks/task.go
Normal file
51
weed/worker/tasks/task.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// UnsupportedTaskTypeError represents an error for unsupported task types
|
||||
type UnsupportedTaskTypeError struct {
|
||||
TaskType types.TaskType
|
||||
}
|
||||
|
||||
// BaseTaskFactory provides common functionality for task factories
|
||||
type BaseTaskFactory struct {
|
||||
taskType types.TaskType
|
||||
capabilities []string
|
||||
description string
|
||||
}
|
||||
|
||||
// NewBaseTaskFactory creates a new base task factory
|
||||
func NewBaseTaskFactory(taskType types.TaskType, capabilities []string, description string) *BaseTaskFactory {
|
||||
return &BaseTaskFactory{
|
||||
taskType: taskType,
|
||||
capabilities: capabilities,
|
||||
description: description,
|
||||
}
|
||||
}
|
||||
|
||||
// Capabilities returns the capabilities required for this task type
|
||||
func (f *BaseTaskFactory) Capabilities() []string {
|
||||
return f.capabilities
|
||||
}
|
||||
|
||||
// Description returns the description of this task type
|
||||
func (f *BaseTaskFactory) Description() string {
|
||||
return f.description
|
||||
}
|
||||
|
||||
// ValidationError represents a parameter validation error
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
// getServerFromSources extracts the server address from unified sources
|
||||
func getServerFromSources(sources []*worker_pb.TaskSource) string {
|
||||
if len(sources) > 0 {
|
||||
return sources[0].Node
|
||||
}
|
||||
return ""
|
||||
}
|
||||
225
weed/worker/tasks/task_log_handler.go
Normal file
225
weed/worker/tasks/task_log_handler.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
)
|
||||
|
||||
// TaskLogHandler handles task log requests from admin server
|
||||
type TaskLogHandler struct {
|
||||
baseLogDir string
|
||||
}
|
||||
|
||||
// NewTaskLogHandler creates a new task log handler
|
||||
func NewTaskLogHandler(baseLogDir string) *TaskLogHandler {
|
||||
if baseLogDir == "" {
|
||||
baseLogDir = "/tmp/seaweedfs/task_logs"
|
||||
}
|
||||
// Best-effort ensure the base directory exists so reads don't fail due to missing dir
|
||||
if err := os.MkdirAll(baseLogDir, 0755); err != nil {
|
||||
glog.Warningf("Failed to create base task log directory %s: %v", baseLogDir, err)
|
||||
}
|
||||
return &TaskLogHandler{
|
||||
baseLogDir: baseLogDir,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogRequest processes a task log request and returns the response
|
||||
func (h *TaskLogHandler) HandleLogRequest(request *worker_pb.TaskLogRequest) *worker_pb.TaskLogResponse {
|
||||
response := &worker_pb.TaskLogResponse{
|
||||
TaskId: request.TaskId,
|
||||
WorkerId: request.WorkerId,
|
||||
Success: false,
|
||||
}
|
||||
|
||||
// Find the task log directory
|
||||
logDir, err := h.findTaskLogDirectory(request.TaskId)
|
||||
if err != nil {
|
||||
response.ErrorMessage = fmt.Sprintf("Task log directory not found: %v", err)
|
||||
glog.Warningf("Task log request failed for %s: %v", request.TaskId, err)
|
||||
|
||||
// Add diagnostic information to help debug the issue
|
||||
response.LogEntries = []*worker_pb.TaskLogEntry{
|
||||
{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Level: "WARNING",
|
||||
Message: fmt.Sprintf("Task logs not available: %v", err),
|
||||
Fields: map[string]string{"source": "task_log_handler"},
|
||||
},
|
||||
{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Level: "INFO",
|
||||
Message: fmt.Sprintf("This usually means the task was never executed on this worker or logs were cleaned up. Base log directory: %s", h.baseLogDir),
|
||||
Fields: map[string]string{"source": "task_log_handler"},
|
||||
},
|
||||
}
|
||||
// response.Success remains false to indicate logs were not found
|
||||
return response
|
||||
}
|
||||
|
||||
// Read metadata if requested
|
||||
if request.IncludeMetadata {
|
||||
metadata, err := h.readTaskMetadata(logDir)
|
||||
if err != nil {
|
||||
response.ErrorMessage = fmt.Sprintf("Failed to read task metadata: %v", err)
|
||||
glog.Warningf("Failed to read metadata for task %s: %v", request.TaskId, err)
|
||||
return response
|
||||
}
|
||||
response.Metadata = metadata
|
||||
}
|
||||
|
||||
// Read log entries
|
||||
logEntries, err := h.readTaskLogEntries(logDir, request)
|
||||
if err != nil {
|
||||
response.ErrorMessage = fmt.Sprintf("Failed to read task logs: %v", err)
|
||||
glog.Warningf("Failed to read logs for task %s: %v", request.TaskId, err)
|
||||
return response
|
||||
}
|
||||
|
||||
response.LogEntries = logEntries
|
||||
response.Success = true
|
||||
|
||||
glog.V(1).Infof("Successfully retrieved %d log entries for task %s", len(logEntries), request.TaskId)
|
||||
return response
|
||||
}
|
||||
|
||||
// findTaskLogDirectory searches for the task log directory by task ID
|
||||
func (h *TaskLogHandler) findTaskLogDirectory(taskID string) (string, error) {
|
||||
entries, err := os.ReadDir(h.baseLogDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read base log directory %s: %w", h.baseLogDir, err)
|
||||
}
|
||||
|
||||
// Look for directories that start with the task ID
|
||||
var candidateDirs []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
candidateDirs = append(candidateDirs, entry.Name())
|
||||
if strings.HasPrefix(entry.Name(), taskID+"_") {
|
||||
return filepath.Join(h.baseLogDir, entry.Name()), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced error message with diagnostic information
|
||||
return "", fmt.Errorf("task log directory not found for task ID: %s (searched %d directories in %s, directories found: %v)",
|
||||
taskID, len(candidateDirs), h.baseLogDir, candidateDirs)
|
||||
}
|
||||
|
||||
// readTaskMetadata reads task metadata from the log directory
|
||||
func (h *TaskLogHandler) readTaskMetadata(logDir string) (*worker_pb.TaskLogMetadata, error) {
|
||||
metadata, err := GetTaskLogMetadata(logDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to protobuf metadata
|
||||
pbMetadata := &worker_pb.TaskLogMetadata{
|
||||
TaskId: metadata.TaskID,
|
||||
TaskType: metadata.TaskType,
|
||||
WorkerId: metadata.WorkerID,
|
||||
StartTime: metadata.StartTime.Unix(),
|
||||
Status: metadata.Status,
|
||||
Progress: float32(metadata.Progress),
|
||||
VolumeId: metadata.VolumeID,
|
||||
Server: metadata.Server,
|
||||
Collection: metadata.Collection,
|
||||
LogFilePath: metadata.LogFilePath,
|
||||
CreatedAt: metadata.CreatedAt.Unix(),
|
||||
CustomData: make(map[string]string),
|
||||
}
|
||||
|
||||
// Set end time and duration if available
|
||||
if metadata.EndTime != nil {
|
||||
pbMetadata.EndTime = metadata.EndTime.Unix()
|
||||
}
|
||||
if metadata.Duration != nil {
|
||||
pbMetadata.DurationMs = metadata.Duration.Milliseconds()
|
||||
}
|
||||
|
||||
// Convert custom data
|
||||
for key, value := range metadata.CustomData {
|
||||
if strValue, ok := value.(string); ok {
|
||||
pbMetadata.CustomData[key] = strValue
|
||||
} else {
|
||||
pbMetadata.CustomData[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
return pbMetadata, nil
|
||||
}
|
||||
|
||||
// readTaskLogEntries reads and filters log entries based on the request
|
||||
func (h *TaskLogHandler) readTaskLogEntries(logDir string, request *worker_pb.TaskLogRequest) ([]*worker_pb.TaskLogEntry, error) {
|
||||
entries, err := ReadTaskLogs(logDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
var filteredEntries []TaskLogEntry
|
||||
|
||||
for _, entry := range entries {
|
||||
// Filter by log level
|
||||
if request.LogLevel != "" && !strings.EqualFold(entry.Level, request.LogLevel) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by time range
|
||||
if request.StartTime > 0 && entry.Timestamp.Unix() < request.StartTime {
|
||||
continue
|
||||
}
|
||||
if request.EndTime > 0 && entry.Timestamp.Unix() > request.EndTime {
|
||||
continue
|
||||
}
|
||||
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
}
|
||||
|
||||
// Limit entries if requested
|
||||
if request.MaxEntries > 0 && len(filteredEntries) > int(request.MaxEntries) {
|
||||
// Take the most recent entries
|
||||
start := len(filteredEntries) - int(request.MaxEntries)
|
||||
filteredEntries = filteredEntries[start:]
|
||||
}
|
||||
|
||||
// Convert to protobuf entries
|
||||
var pbEntries []*worker_pb.TaskLogEntry
|
||||
for _, entry := range filteredEntries {
|
||||
pbEntry := &worker_pb.TaskLogEntry{
|
||||
Timestamp: entry.Timestamp.Unix(),
|
||||
Level: entry.Level,
|
||||
Message: entry.Message,
|
||||
Fields: make(map[string]string),
|
||||
}
|
||||
|
||||
// Set progress if available
|
||||
if entry.Progress != nil {
|
||||
pbEntry.Progress = float32(*entry.Progress)
|
||||
}
|
||||
|
||||
// Set status if available
|
||||
if entry.Status != nil {
|
||||
pbEntry.Status = *entry.Status
|
||||
}
|
||||
|
||||
// Convert fields
|
||||
for key, value := range entry.Fields {
|
||||
if strValue, ok := value.(string); ok {
|
||||
pbEntry.Fields[key] = strValue
|
||||
} else {
|
||||
pbEntry.Fields[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
pbEntries = append(pbEntries, pbEntry)
|
||||
}
|
||||
|
||||
return pbEntries, nil
|
||||
}
|
||||
444
weed/worker/tasks/task_logger.go
Normal file
444
weed/worker/tasks/task_logger.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// TaskLogger provides file-based logging for individual tasks
|
||||
type TaskLogger interface {
|
||||
// Log methods
|
||||
Info(message string, args ...interface{})
|
||||
Warning(message string, args ...interface{})
|
||||
Error(message string, args ...interface{})
|
||||
Debug(message string, args ...interface{})
|
||||
|
||||
// Progress and status logging
|
||||
LogProgress(progress float64, message string)
|
||||
LogStatus(status string, message string)
|
||||
|
||||
// Structured logging
|
||||
LogWithFields(level string, message string, fields map[string]interface{})
|
||||
|
||||
// Lifecycle
|
||||
Sync() error
|
||||
Close() error
|
||||
GetLogDir() string
|
||||
}
|
||||
|
||||
// LoggerProvider interface for tasks that support logging
|
||||
type LoggerProvider interface {
|
||||
InitializeTaskLogger(taskID string, workerID string, params types.TaskParams) error
|
||||
GetTaskLogger() TaskLogger
|
||||
}
|
||||
|
||||
// TaskLoggerConfig holds configuration for task logging
|
||||
type TaskLoggerConfig struct {
|
||||
BaseLogDir string
|
||||
MaxTasks int // Maximum number of task logs to keep
|
||||
MaxLogSizeMB int // Maximum log file size in MB
|
||||
EnableConsole bool // Also log to console
|
||||
}
|
||||
|
||||
// FileTaskLogger implements TaskLogger using file-based logging
|
||||
type FileTaskLogger struct {
|
||||
taskID string
|
||||
taskType types.TaskType
|
||||
workerID string
|
||||
logDir string
|
||||
logFile *os.File
|
||||
mutex sync.Mutex
|
||||
config TaskLoggerConfig
|
||||
metadata *TaskLogMetadata
|
||||
closed bool
|
||||
}
|
||||
|
||||
// TaskLogMetadata contains metadata about the task execution
|
||||
type TaskLogMetadata struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
Duration *time.Duration `json:"duration,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
VolumeID uint32 `json:"volume_id,omitempty"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Collection string `json:"collection,omitempty"`
|
||||
CustomData map[string]interface{} `json:"custom_data,omitempty"`
|
||||
LogFilePath string `json:"log_file_path"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TaskLogEntry represents a single log entry
|
||||
type TaskLogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultTaskLoggerConfig returns default configuration
|
||||
func DefaultTaskLoggerConfig() TaskLoggerConfig {
|
||||
return TaskLoggerConfig{
|
||||
BaseLogDir: "/data/task_logs", // Use persistent data directory
|
||||
MaxTasks: 100, // Keep last 100 task logs
|
||||
MaxLogSizeMB: 10,
|
||||
EnableConsole: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskLogger creates a new file-based task logger
|
||||
func NewTaskLogger(taskID string, taskType types.TaskType, workerID string, params types.TaskParams, config TaskLoggerConfig) (TaskLogger, error) {
|
||||
// Create unique directory name with timestamp
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
dirName := fmt.Sprintf("%s_%s_%s_%s", taskID, taskType, workerID, timestamp)
|
||||
logDir := filepath.Join(config.BaseLogDir, dirName)
|
||||
|
||||
// Create log directory
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create log directory %s: %w", logDir, err)
|
||||
}
|
||||
|
||||
// Create log file
|
||||
logFilePath := filepath.Join(logDir, "task.log")
|
||||
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file %s: %w", logFilePath, err)
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
metadata := &TaskLogMetadata{
|
||||
TaskID: taskID,
|
||||
TaskType: string(taskType),
|
||||
WorkerID: workerID,
|
||||
StartTime: time.Now(),
|
||||
Status: "started",
|
||||
Progress: 0.0,
|
||||
VolumeID: params.VolumeID,
|
||||
Server: getServerFromSources(params.TypedParams.Sources),
|
||||
Collection: params.Collection,
|
||||
CustomData: make(map[string]interface{}),
|
||||
LogFilePath: logFilePath,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
logger := &FileTaskLogger{
|
||||
taskID: taskID,
|
||||
taskType: taskType,
|
||||
workerID: workerID,
|
||||
logDir: logDir,
|
||||
logFile: logFile,
|
||||
config: config,
|
||||
metadata: metadata,
|
||||
closed: false,
|
||||
}
|
||||
|
||||
// Write initial log entry
|
||||
logger.Info("Task logger initialized for %s (type: %s, worker: %s)", taskID, taskType, workerID)
|
||||
logger.LogWithFields("INFO", "Task parameters", map[string]interface{}{
|
||||
"volume_id": params.VolumeID,
|
||||
"server": getServerFromSources(params.TypedParams.Sources),
|
||||
"collection": params.Collection,
|
||||
})
|
||||
|
||||
// Save initial metadata
|
||||
if err := logger.saveMetadata(); err != nil {
|
||||
glog.Warningf("Failed to save initial task metadata: %v", err)
|
||||
}
|
||||
|
||||
// Clean up old task logs
|
||||
go logger.cleanupOldLogs()
|
||||
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (l *FileTaskLogger) Info(message string, args ...interface{}) {
|
||||
l.log("INFO", message, args...)
|
||||
}
|
||||
|
||||
// Warning logs a warning message
|
||||
func (l *FileTaskLogger) Warning(message string, args ...interface{}) {
|
||||
l.log("WARNING", message, args...)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *FileTaskLogger) Error(message string, args ...interface{}) {
|
||||
l.log("ERROR", message, args...)
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *FileTaskLogger) Debug(message string, args ...interface{}) {
|
||||
l.log("DEBUG", message, args...)
|
||||
}
|
||||
|
||||
// LogProgress logs task progress
|
||||
func (l *FileTaskLogger) LogProgress(progress float64, message string) {
|
||||
l.mutex.Lock()
|
||||
l.metadata.Progress = progress
|
||||
l.mutex.Unlock()
|
||||
|
||||
entry := TaskLogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: "INFO",
|
||||
Message: message,
|
||||
Progress: &progress,
|
||||
}
|
||||
|
||||
l.writeLogEntry(entry)
|
||||
// Update metadata with new progress
|
||||
if err := l.saveMetadata(); err != nil {
|
||||
glog.Warningf("Failed to save initial task metadata: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogStatus logs task status change
|
||||
func (l *FileTaskLogger) LogStatus(status string, message string) {
|
||||
l.mutex.Lock()
|
||||
l.metadata.Status = status
|
||||
l.mutex.Unlock()
|
||||
|
||||
entry := TaskLogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: "INFO",
|
||||
Message: message,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
l.writeLogEntry(entry)
|
||||
// Update metadata with new status
|
||||
if err := l.saveMetadata(); err != nil {
|
||||
glog.Warningf("Failed to update metadata with new status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogWithFields logs a message with structured fields
|
||||
func (l *FileTaskLogger) LogWithFields(level string, message string, fields map[string]interface{}) {
|
||||
entry := TaskLogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Message: message,
|
||||
Fields: fields,
|
||||
}
|
||||
|
||||
l.writeLogEntry(entry)
|
||||
}
|
||||
|
||||
// Sync flushes buffered data to disk
|
||||
func (l *FileTaskLogger) Sync() error {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
if l.logFile != nil {
|
||||
return l.logFile.Sync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the logger and finalizes metadata
|
||||
func (l *FileTaskLogger) Close() error {
|
||||
l.Info("Task logger closed for %s", l.taskID)
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finalize metadata
|
||||
endTime := time.Now()
|
||||
duration := endTime.Sub(l.metadata.StartTime)
|
||||
l.metadata.EndTime = &endTime
|
||||
l.metadata.Duration = &duration
|
||||
|
||||
if l.metadata.Status == "started" {
|
||||
l.metadata.Status = "completed"
|
||||
}
|
||||
|
||||
// Save final metadata
|
||||
if err := l.saveMetadata(); err != nil {
|
||||
glog.Warningf("Failed to save final task metadata: %v", err)
|
||||
}
|
||||
|
||||
// Close log file
|
||||
if l.logFile != nil {
|
||||
if err := l.logFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close log file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
l.closed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLogDir returns the log directory path
|
||||
func (l *FileTaskLogger) GetLogDir() string {
|
||||
return l.logDir
|
||||
}
|
||||
|
||||
// log is the internal logging method
|
||||
func (l *FileTaskLogger) log(level string, message string, args ...interface{}) {
|
||||
formattedMessage := fmt.Sprintf(message, args...)
|
||||
|
||||
entry := TaskLogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Message: formattedMessage,
|
||||
}
|
||||
|
||||
l.writeLogEntry(entry)
|
||||
}
|
||||
|
||||
// writeLogEntry writes a log entry to the file
|
||||
func (l *FileTaskLogger) writeLogEntry(entry TaskLogEntry) {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
if l.closed || l.logFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Format as JSON line
|
||||
jsonData, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal log entry: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if _, err := l.logFile.WriteString(string(jsonData) + "\n"); err != nil {
|
||||
glog.Errorf("Failed to write log entry: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Only forward errors and warnings to glog; routine task logs stay in the task log file.
|
||||
if l.config.EnableConsole {
|
||||
formattedMsg := fmt.Sprintf("[TASK-%s] %s: %s", l.taskID, entry.Level, entry.Message)
|
||||
switch entry.Level {
|
||||
case "ERROR":
|
||||
glog.ErrorDepth(4, formattedMsg)
|
||||
case "WARNING":
|
||||
glog.WarningDepth(4, formattedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveMetadata saves task metadata to file
|
||||
func (l *FileTaskLogger) saveMetadata() error {
|
||||
metadataPath := filepath.Join(l.logDir, "metadata.json")
|
||||
|
||||
data, err := json.MarshalIndent(l.metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(metadataPath, data, 0644)
|
||||
}
|
||||
|
||||
// cleanupOldLogs removes old task log directories to maintain the limit
|
||||
func (l *FileTaskLogger) cleanupOldLogs() {
|
||||
baseDir := l.config.BaseLogDir
|
||||
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to read log directory %s: %v", baseDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter for directories only
|
||||
var dirs []os.DirEntry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// If we're under the limit, nothing to clean
|
||||
if len(dirs) <= l.config.MaxTasks {
|
||||
return
|
||||
}
|
||||
|
||||
// Sort by modification time (oldest first)
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
infoI, errI := dirs[i].Info()
|
||||
infoJ, errJ := dirs[j].Info()
|
||||
if errI != nil || errJ != nil {
|
||||
return false
|
||||
}
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
// Remove oldest directories
|
||||
numToRemove := len(dirs) - l.config.MaxTasks
|
||||
for i := 0; i < numToRemove; i++ {
|
||||
dirPath := filepath.Join(baseDir, dirs[i].Name())
|
||||
if err := os.RemoveAll(dirPath); err != nil {
|
||||
glog.Warningf("Failed to remove old log directory %s: %v", dirPath, err)
|
||||
} else {
|
||||
glog.V(1).Infof("Cleaned up old task log directory: %s", dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Task log cleanup completed: removed %d old directories", numToRemove)
|
||||
}
|
||||
|
||||
// GetTaskLogMetadata reads metadata from a task log directory
|
||||
func GetTaskLogMetadata(logDir string) (*TaskLogMetadata, error) {
|
||||
metadataPath := filepath.Join(logDir, "metadata.json")
|
||||
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read metadata file: %w", err)
|
||||
}
|
||||
|
||||
var metadata TaskLogMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// ReadTaskLogs reads all log entries from a task log file
|
||||
func ReadTaskLogs(logDir string) ([]TaskLogEntry, error) {
|
||||
logPath := filepath.Join(logDir, "task.log")
|
||||
|
||||
file, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var entries []TaskLogEntry
|
||||
decoder := json.NewDecoder(file)
|
||||
|
||||
for {
|
||||
var entry TaskLogEntry
|
||||
if err := decoder.Decode(&entry); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
// If we fail to decode an entry, it might be a partial write at the end of the file
|
||||
// Return what we have so far instead of failing the entire request
|
||||
glog.V(1).Infof("Failed to decode log entry in %s: %v (returning %d partial logs)", logPath, err, len(entries))
|
||||
break
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
79
weed/worker/tasks/ui_base.go
Normal file
79
weed/worker/tasks/ui_base.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// BaseUIProvider provides common UIProvider functionality for all tasks
|
||||
type BaseUIProvider struct {
|
||||
taskType types.TaskType
|
||||
displayName string
|
||||
description string
|
||||
icon string
|
||||
schemaFunc func() *TaskConfigSchema
|
||||
configFunc func() types.TaskConfig
|
||||
applyTaskPolicyFunc func(policy *worker_pb.TaskPolicy) error
|
||||
applyTaskConfigFunc func(config types.TaskConfig) error
|
||||
}
|
||||
|
||||
// NewBaseUIProvider creates a new base UI provider
|
||||
func NewBaseUIProvider(
|
||||
taskType types.TaskType,
|
||||
displayName string,
|
||||
description string,
|
||||
icon string,
|
||||
schemaFunc func() *TaskConfigSchema,
|
||||
configFunc func() types.TaskConfig,
|
||||
applyTaskPolicyFunc func(policy *worker_pb.TaskPolicy) error,
|
||||
applyTaskConfigFunc func(config types.TaskConfig) error,
|
||||
) *BaseUIProvider {
|
||||
return &BaseUIProvider{
|
||||
taskType: taskType,
|
||||
displayName: displayName,
|
||||
description: description,
|
||||
icon: icon,
|
||||
schemaFunc: schemaFunc,
|
||||
configFunc: configFunc,
|
||||
applyTaskPolicyFunc: applyTaskPolicyFunc,
|
||||
applyTaskConfigFunc: applyTaskConfigFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaskType returns the task type
|
||||
func (ui *BaseUIProvider) GetTaskType() types.TaskType {
|
||||
return ui.taskType
|
||||
}
|
||||
|
||||
// GetDisplayName returns the human-readable name
|
||||
func (ui *BaseUIProvider) GetDisplayName() string {
|
||||
return ui.displayName
|
||||
}
|
||||
|
||||
// GetDescription returns a description of what this task does
|
||||
func (ui *BaseUIProvider) GetDescription() string {
|
||||
return ui.description
|
||||
}
|
||||
|
||||
// GetIcon returns the icon CSS class for this task type
|
||||
func (ui *BaseUIProvider) GetIcon() string {
|
||||
return ui.icon
|
||||
}
|
||||
|
||||
// GetCurrentConfig returns the current configuration as TaskConfig
|
||||
func (ui *BaseUIProvider) GetCurrentConfig() types.TaskConfig {
|
||||
return ui.configFunc()
|
||||
}
|
||||
|
||||
// ApplyTaskPolicy applies protobuf TaskPolicy configuration
|
||||
func (ui *BaseUIProvider) ApplyTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
return ui.applyTaskPolicyFunc(policy)
|
||||
}
|
||||
|
||||
// ApplyTaskConfig applies TaskConfig interface configuration
|
||||
func (ui *BaseUIProvider) ApplyTaskConfig(config types.TaskConfig) error {
|
||||
return ui.applyTaskConfigFunc(config)
|
||||
}
|
||||
|
||||
// RegisterUIFunc provides a common registration function signature
|
||||
type RegisterUIFunc[D, S any] func(uiRegistry *types.UIRegistry, detector D, scheduler S)
|
||||
23
weed/worker/tasks/util/address.go
Normal file
23
weed/worker/tasks/util/address.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
)
|
||||
|
||||
// ResolveServerAddress resolves a server ID to its network address using the active topology
|
||||
func ResolveServerAddress(serverID string, activeTopology *topology.ActiveTopology) (string, error) {
|
||||
if activeTopology == nil {
|
||||
return "", fmt.Errorf("topology not available")
|
||||
}
|
||||
allNodes := activeTopology.GetAllNodes()
|
||||
nodeInfo, exists := allNodes[serverID]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("server %s not found in topology", serverID)
|
||||
}
|
||||
// Note: We use NewServerAddressFromDataNode here because it encodes the GRPC port into the server address
|
||||
// if it is non-zero, avoiding the default +10000 GRPC port offset.
|
||||
return string(pb.NewServerAddressFromDataNode(nodeInfo)), nil
|
||||
}
|
||||
183
weed/worker/tasks/vacuum/config.go
Normal file
183
weed/worker/tasks/vacuum/config.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/config"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
)
|
||||
|
||||
// Config extends BaseConfig with vacuum-specific settings
|
||||
type Config struct {
|
||||
base.BaseConfig
|
||||
GarbageThreshold float64 `json:"garbage_threshold"`
|
||||
MinVolumeAgeSeconds int `json:"min_volume_age_seconds"`
|
||||
}
|
||||
|
||||
// NewDefaultConfig creates a new default vacuum configuration
|
||||
func NewDefaultConfig() *Config {
|
||||
return &Config{
|
||||
BaseConfig: base.BaseConfig{
|
||||
Enabled: true,
|
||||
ScanIntervalSeconds: 2 * 60 * 60, // 2 hours
|
||||
MaxConcurrent: 2,
|
||||
},
|
||||
GarbageThreshold: 0.3, // 30%
|
||||
MinVolumeAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
}
|
||||
}
|
||||
|
||||
// ToTaskPolicy converts configuration to a TaskPolicy protobuf message
|
||||
func (c *Config) ToTaskPolicy() *worker_pb.TaskPolicy {
|
||||
return &worker_pb.TaskPolicy{
|
||||
Enabled: c.Enabled,
|
||||
MaxConcurrent: int32(c.MaxConcurrent),
|
||||
RepeatIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
CheckIntervalSeconds: int32(c.ScanIntervalSeconds),
|
||||
TaskConfig: &worker_pb.TaskPolicy_VacuumConfig{
|
||||
VacuumConfig: &worker_pb.VacuumTaskConfig{
|
||||
GarbageThreshold: float64(c.GarbageThreshold),
|
||||
MinVolumeAgeHours: int32((c.MinVolumeAgeSeconds + 3599) / 3600), // round up so sub-hour values don't become 0
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// FromTaskPolicy loads configuration from a TaskPolicy protobuf message
|
||||
func (c *Config) FromTaskPolicy(policy *worker_pb.TaskPolicy) error {
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy is nil")
|
||||
}
|
||||
|
||||
// Set general TaskPolicy fields
|
||||
c.Enabled = policy.Enabled
|
||||
c.MaxConcurrent = int(policy.MaxConcurrent)
|
||||
c.ScanIntervalSeconds = int(policy.RepeatIntervalSeconds) // Direct seconds-to-seconds mapping
|
||||
|
||||
// Set vacuum-specific fields from the task config
|
||||
if vacuumConfig := policy.GetVacuumConfig(); vacuumConfig != nil {
|
||||
c.GarbageThreshold = float64(vacuumConfig.GarbageThreshold)
|
||||
c.MinVolumeAgeSeconds = int(vacuumConfig.MinVolumeAgeHours * 3600) // Convert hours to seconds
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigFromPersistence loads configuration from the persistence layer if available
|
||||
func LoadConfigFromPersistence(configPersistence interface{}) *Config {
|
||||
config := NewDefaultConfig()
|
||||
|
||||
// Try to load from persistence if available
|
||||
if persistence, ok := configPersistence.(interface {
|
||||
LoadVacuumTaskPolicy() (*worker_pb.TaskPolicy, error)
|
||||
}); ok {
|
||||
policy, err := persistence.LoadVacuumTaskPolicy()
|
||||
switch {
|
||||
case err != nil:
|
||||
glog.Warningf("Could not read the persisted vacuum configuration, falling back to defaults: %v", err)
|
||||
case policy == nil:
|
||||
glog.V(1).Infof("No vacuum configuration persisted yet, using defaults")
|
||||
default:
|
||||
if err := config.FromTaskPolicy(policy); err != nil {
|
||||
glog.Warningf("Could not apply the persisted vacuum configuration, falling back to defaults: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("Loaded vacuum configuration from persistence")
|
||||
return config
|
||||
}
|
||||
}
|
||||
} else if configPersistence != nil {
|
||||
// A store was handed in but does not expose the accessor, so the persisted
|
||||
// settings are silently ignored - always a wiring bug, never a normal state.
|
||||
glog.Warningf("%T cannot provide the persisted vacuum configuration: it has no LoadVacuumTaskPolicy() method, "+
|
||||
"so the compiled-in defaults are used and any saved vacuum settings are ignored", configPersistence)
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Using default vacuum configuration")
|
||||
return config
|
||||
}
|
||||
|
||||
// GetConfigSpec returns the configuration schema for vacuum tasks
|
||||
func GetConfigSpec() base.ConfigSpec {
|
||||
return base.ConfigSpec{
|
||||
Fields: []*config.Field{
|
||||
{
|
||||
Name: "enabled",
|
||||
JSONName: "enabled",
|
||||
Type: config.FieldTypeBool,
|
||||
DefaultValue: true,
|
||||
Required: false,
|
||||
DisplayName: "Enable Vacuum Tasks",
|
||||
Description: "Whether vacuum tasks should be automatically created",
|
||||
HelpText: "Toggle this to enable or disable automatic vacuum task generation",
|
||||
InputType: "checkbox",
|
||||
CSSClasses: "form-check-input",
|
||||
},
|
||||
{
|
||||
Name: "scan_interval_seconds",
|
||||
JSONName: "scan_interval_seconds",
|
||||
Type: config.FieldTypeInterval,
|
||||
DefaultValue: 2 * 60 * 60,
|
||||
MinValue: 10 * 60,
|
||||
MaxValue: 24 * 60 * 60,
|
||||
Required: true,
|
||||
DisplayName: "Scan Interval",
|
||||
Description: "How often to scan for volumes needing vacuum",
|
||||
HelpText: "The system will check for volumes that need vacuuming at this interval",
|
||||
Placeholder: "2",
|
||||
Unit: config.UnitHours,
|
||||
InputType: "interval",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "max_concurrent",
|
||||
JSONName: "max_concurrent",
|
||||
Type: config.FieldTypeInt,
|
||||
DefaultValue: 2,
|
||||
MinValue: 1,
|
||||
MaxValue: 10,
|
||||
Required: true,
|
||||
DisplayName: "Max Concurrent Tasks",
|
||||
Description: "Maximum number of vacuum tasks that can run simultaneously",
|
||||
HelpText: "Limits the number of vacuum operations running at the same time to control system load",
|
||||
Placeholder: "2 (default)",
|
||||
Unit: config.UnitCount,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "garbage_threshold",
|
||||
JSONName: "garbage_threshold",
|
||||
Type: config.FieldTypeFloat,
|
||||
DefaultValue: 0.3,
|
||||
MinValue: 0.0,
|
||||
MaxValue: 1.0,
|
||||
Required: true,
|
||||
DisplayName: "Garbage Percentage Threshold",
|
||||
Description: "Trigger vacuum when garbage ratio exceeds this percentage",
|
||||
HelpText: "Volumes with more deleted content than this threshold will be vacuumed",
|
||||
Placeholder: "0.30 (30%)",
|
||||
Unit: config.UnitNone,
|
||||
InputType: "number",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
{
|
||||
Name: "min_volume_age_seconds",
|
||||
JSONName: "min_volume_age_seconds",
|
||||
Type: config.FieldTypeInterval,
|
||||
DefaultValue: 24 * 60 * 60,
|
||||
MinValue: 1 * 60 * 60,
|
||||
MaxValue: 7 * 24 * 60 * 60,
|
||||
Required: true,
|
||||
DisplayName: "Minimum Volume Age",
|
||||
Description: "Only vacuum volumes older than this duration",
|
||||
HelpText: "Prevents vacuuming of recently created volumes that may still be actively written to",
|
||||
Placeholder: "24",
|
||||
Unit: config.UnitHours,
|
||||
InputType: "interval",
|
||||
CSSClasses: "form-control",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
75
weed/worker/tasks/vacuum/config_persistence_test.go
Normal file
75
weed/worker/tasks/vacuum/config_persistence_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
)
|
||||
|
||||
type stubVacuumStore struct {
|
||||
policy *worker_pb.TaskPolicy
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubVacuumStore) LoadVacuumTaskPolicy() (*worker_pb.TaskPolicy, error) {
|
||||
return s.policy, s.err
|
||||
}
|
||||
|
||||
// wrongShapedStore is what the maintenance manager used to be handed: a non-nil value that
|
||||
// does not satisfy the accessor the loader asserts on. It has to keep falling back to the
|
||||
// defaults, but no longer silently: a fallback the operator cannot see reads as a disabled task
|
||||
// that keeps running.
|
||||
type wrongShapedStore struct{}
|
||||
|
||||
func (wrongShapedStore) SomethingElse() {}
|
||||
|
||||
func TestLoadConfigFromPersistenceUsesPersistedPolicy(t *testing.T) {
|
||||
persisted := NewDefaultConfig()
|
||||
persisted.Enabled = false
|
||||
persisted.GarbageThreshold = 0.75
|
||||
persisted.MaxConcurrent = 5
|
||||
|
||||
loaded := LoadConfigFromPersistence(&stubVacuumStore{policy: persisted.ToTaskPolicy()})
|
||||
if loaded == nil {
|
||||
t.Fatal("LoadConfigFromPersistence returned nil")
|
||||
}
|
||||
if loaded.Enabled {
|
||||
t.Error("enabled = true, want the persisted false")
|
||||
}
|
||||
if loaded.GarbageThreshold != 0.75 {
|
||||
t.Errorf("garbage threshold = %v, want the persisted 0.75", loaded.GarbageThreshold)
|
||||
}
|
||||
if loaded.MaxConcurrent != 5 {
|
||||
t.Errorf("max concurrent = %d, want the persisted 5", loaded.MaxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromPersistenceFallsBackToDefaults(t *testing.T) {
|
||||
defaults := NewDefaultConfig()
|
||||
|
||||
cases := map[string]interface{}{
|
||||
"no store configured": nil,
|
||||
"store without the value": &stubVacuumStore{},
|
||||
"store that errors": &stubVacuumStore{err: errors.New("disk on fire")},
|
||||
"store of the wrong type": wrongShapedStore{},
|
||||
}
|
||||
|
||||
for name, store := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
loaded := LoadConfigFromPersistence(store)
|
||||
if loaded == nil {
|
||||
t.Fatal("LoadConfigFromPersistence returned nil")
|
||||
}
|
||||
if !loaded.Enabled {
|
||||
t.Error("enabled = false, want the compiled-in default true")
|
||||
}
|
||||
if loaded.GarbageThreshold != defaults.GarbageThreshold {
|
||||
t.Errorf("garbage threshold = %v, want the default %v", loaded.GarbageThreshold, defaults.GarbageThreshold)
|
||||
}
|
||||
if loaded.ScanIntervalSeconds != defaults.ScanIntervalSeconds {
|
||||
t.Errorf("scan interval = %d, want the default %d", loaded.ScanIntervalSeconds, defaults.ScanIntervalSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
227
weed/worker/tasks/vacuum/detection.go
Normal file
227
weed/worker/tasks/vacuum/detection.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Detection implements the detection logic for vacuum tasks
|
||||
func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo, config base.TaskConfig) ([]*types.TaskDetectionResult, error) {
|
||||
if !config.IsEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
vacuumConfig := config.(*Config)
|
||||
minVolumeAge := time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second
|
||||
|
||||
type volumeBucket struct {
|
||||
replicas []*types.VolumeHealthMetrics
|
||||
}
|
||||
buckets := make(map[uint32]*volumeBucket)
|
||||
order := make([]uint32, 0)
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
b, ok := buckets[m.VolumeID]
|
||||
if !ok {
|
||||
b = &volumeBucket{}
|
||||
buckets[m.VolumeID] = b
|
||||
order = append(order, m.VolumeID)
|
||||
}
|
||||
b.replicas = append(b.replicas, m)
|
||||
}
|
||||
|
||||
var results []*types.TaskDetectionResult
|
||||
skippedDueToGarbage := 0
|
||||
skippedDueToAge := 0
|
||||
debugCount := 0
|
||||
|
||||
for _, vid := range order {
|
||||
replicas := buckets[vid].replicas
|
||||
|
||||
// Pick the replica with the highest garbage ratio that also satisfies
|
||||
// the minimum-age gate as the "primary" used for priority + reason.
|
||||
// Master built-in vacuum filters per-replica again inside
|
||||
// batchVacuumVolumeCheck, so it's fine if only some replicas are
|
||||
// above threshold here — the worker's checkVacuumEligibility will
|
||||
// filter again at execute time.
|
||||
primary := pickPrimaryReplica(replicas, vacuumConfig.GarbageThreshold, minVolumeAge)
|
||||
if primary == nil {
|
||||
recordSkipReasons(replicas, vacuumConfig.GarbageThreshold, minVolumeAge, &skippedDueToGarbage, &skippedDueToAge, &debugCount)
|
||||
continue
|
||||
}
|
||||
|
||||
priority := types.TaskPriorityNormal
|
||||
if primary.GarbageRatio > 0.6 {
|
||||
priority = types.TaskPriorityHigh
|
||||
}
|
||||
|
||||
taskID := fmt.Sprintf("vacuum_vol_%d_%d", vid, time.Now().Unix())
|
||||
|
||||
result := &types.TaskDetectionResult{
|
||||
TaskID: taskID,
|
||||
TaskType: types.TaskTypeVacuum,
|
||||
VolumeID: vid,
|
||||
Server: primary.Server,
|
||||
Collection: primary.Collection,
|
||||
Priority: priority,
|
||||
Reason: "Volume has excessive garbage requiring vacuum",
|
||||
ScheduleAt: time.Now(),
|
||||
}
|
||||
|
||||
// Check if ANY task already exists in ActiveTopology for this volume
|
||||
if clusterInfo != nil && clusterInfo.ActiveTopology != nil {
|
||||
if clusterInfo.ActiveTopology.HasAnyTask(vid) {
|
||||
glog.V(2).Infof("VACUUM: Skipping volume %d, task already exists in ActiveTopology", vid)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result.TypedParams = createVacuumTaskParams(result, replicas, vacuumConfig, clusterInfo)
|
||||
if result.TypedParams != nil {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 && len(metrics) > 0 {
|
||||
totalVolumes := len(metrics)
|
||||
glog.V(1).Infof("VACUUM: No tasks created for %d volume replicas across %d volumes. Threshold=%.2f%%, MinAge=%s. Skipped: %d (garbage<threshold), %d (age<minimum)",
|
||||
totalVolumes, len(buckets), vacuumConfig.GarbageThreshold*100, minVolumeAge, skippedDueToGarbage, skippedDueToAge)
|
||||
|
||||
for i, metric := range metrics {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
glog.V(1).Infof("VACUUM: Volume %d on %s: garbage=%.2f%% (need ≥%.2f%%), age=%s (need ≥%s)",
|
||||
metric.VolumeID, metric.Server, metric.GarbageRatio*100, vacuumConfig.GarbageThreshold*100,
|
||||
metric.Age.Truncate(time.Minute), minVolumeAge.Truncate(time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// pickPrimaryReplica returns the eligible replica with the highest garbage
|
||||
// ratio. Returns nil if no replica satisfies both gates.
|
||||
func pickPrimaryReplica(replicas []*types.VolumeHealthMetrics, garbageThreshold float64, minAge time.Duration) *types.VolumeHealthMetrics {
|
||||
var best *types.VolumeHealthMetrics
|
||||
for _, m := range replicas {
|
||||
if m.GarbageRatio < garbageThreshold {
|
||||
continue
|
||||
}
|
||||
if m.Age < minAge {
|
||||
continue
|
||||
}
|
||||
if best == nil || m.GarbageRatio > best.GarbageRatio {
|
||||
best = m
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// recordSkipReasons tallies skip counters for debug logging when no
|
||||
// replica of a volume qualified for vacuum.
|
||||
func recordSkipReasons(replicas []*types.VolumeHealthMetrics, garbageThreshold float64, minAge time.Duration, garbageSkip, ageSkip, debugCount *int) {
|
||||
for _, m := range replicas {
|
||||
if *debugCount >= 5 {
|
||||
return
|
||||
}
|
||||
if m.GarbageRatio < garbageThreshold {
|
||||
*garbageSkip++
|
||||
}
|
||||
if m.Age < minAge {
|
||||
*ageSkip++
|
||||
}
|
||||
*debugCount++
|
||||
}
|
||||
}
|
||||
|
||||
// createVacuumTaskParams creates typed parameters for a vacuum task whose
|
||||
// Sources list contains every replica of the volume. Worker-side
|
||||
// performVacuum iterates over Sources and vacuums each replica.
|
||||
func createVacuumTaskParams(task *types.TaskDetectionResult, replicas []*types.VolumeHealthMetrics, vacuumConfig *Config, clusterInfo *types.ClusterInfo) *worker_pb.TaskParams {
|
||||
garbageThreshold := 0.3
|
||||
verifyChecksum := true
|
||||
batchSize := int32(1000)
|
||||
workingDir := ""
|
||||
|
||||
if vacuumConfig != nil {
|
||||
garbageThreshold = vacuumConfig.GarbageThreshold
|
||||
}
|
||||
|
||||
if clusterInfo == nil || clusterInfo.ActiveTopology == nil {
|
||||
glog.Errorf("Topology not available for vacuum task on volume %d, skipping", task.VolumeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
sources := make([]*worker_pb.TaskSource, 0, len(replicas))
|
||||
seen := make(map[string]struct{}, len(replicas))
|
||||
for _, m := range replicas {
|
||||
address, err := util.ResolveServerAddress(m.Server, clusterInfo.ActiveTopology)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to resolve address for server %s for vacuum task on volume %d, dropping replica: %v", m.Server, task.VolumeID, err)
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[address]; ok {
|
||||
continue
|
||||
}
|
||||
seen[address] = struct{}{}
|
||||
sources = append(sources, &worker_pb.TaskSource{
|
||||
Node: address,
|
||||
VolumeId: task.VolumeID,
|
||||
EstimatedSize: m.Size,
|
||||
DataCenter: m.DataCenter,
|
||||
Rack: m.Rack,
|
||||
})
|
||||
}
|
||||
|
||||
if len(sources) == 0 {
|
||||
glog.Errorf("No resolvable replicas for vacuum task on volume %d, skipping", task.VolumeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the primary replica (matches task.Server) for the canonical size
|
||||
// recorded in TaskParams. Master built-in keeps a single volumeSizeLimit
|
||||
// here so timeouts are stable across replicas.
|
||||
canonical := primaryReplicaByServer(replicas, task.Server)
|
||||
var canonicalSize uint64
|
||||
if canonical != nil {
|
||||
canonicalSize = canonical.Size
|
||||
}
|
||||
|
||||
return &worker_pb.TaskParams{
|
||||
TaskId: task.TaskID,
|
||||
VolumeId: task.VolumeID,
|
||||
Collection: task.Collection,
|
||||
VolumeSize: canonicalSize,
|
||||
Sources: sources,
|
||||
TaskParams: &worker_pb.TaskParams_VacuumParams{
|
||||
VacuumParams: &worker_pb.VacuumTaskParams{
|
||||
GarbageThreshold: garbageThreshold,
|
||||
ForceVacuum: false,
|
||||
BatchSize: batchSize,
|
||||
WorkingDir: workingDir,
|
||||
VerifyChecksum: verifyChecksum,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func primaryReplicaByServer(replicas []*types.VolumeHealthMetrics, server string) *types.VolumeHealthMetrics {
|
||||
for _, m := range replicas {
|
||||
if m.Server == server {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if len(replicas) > 0 {
|
||||
return replicas[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
611
weed/worker/tasks/vacuum/plugin_handler.go
Normal file
611
weed/worker/tasks/vacuum/plugin_handler.go
Normal file
@@ -0,0 +1,611 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultVacuumTaskBatchSize = int32(1000)
|
||||
DefaultMaxExecutionConcurrency = int32(2)
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
||||
JobType: "vacuum",
|
||||
Category: pluginworker.CategoryDefault,
|
||||
Aliases: []string{"vol.vacuum", "volume.vacuum"},
|
||||
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
||||
return NewVacuumHandler(opts.GrpcDialOption, int32(opts.MaxExecute)), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// VacuumHandler is the plugin job handler for vacuum job type.
|
||||
type VacuumHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
maxExecutionConcurrency int32
|
||||
}
|
||||
|
||||
// NewVacuumHandler creates a VacuumHandler with the given gRPC dial option and
|
||||
// maximum execution concurrency. When maxExecutionConcurrency is zero or
|
||||
// negative, DefaultMaxExecutionConcurrency is used as the fallback.
|
||||
func NewVacuumHandler(grpcDialOption grpc.DialOption, maxExecutionConcurrency int32) *VacuumHandler {
|
||||
return &VacuumHandler{grpcDialOption: grpcDialOption, maxExecutionConcurrency: maxExecutionConcurrency}
|
||||
}
|
||||
|
||||
// Capability returns the job type capability for the vacuum handler.
|
||||
// MaxExecutionConcurrency reflects the value passed at construction time,
|
||||
// falling back to DefaultMaxExecutionConcurrency when unset.
|
||||
func (h *VacuumHandler) Capability() *plugin_pb.JobTypeCapability {
|
||||
maxExec := h.maxExecutionConcurrency
|
||||
if maxExec <= 0 {
|
||||
maxExec = DefaultMaxExecutionConcurrency
|
||||
}
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: "vacuum",
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: maxExec,
|
||||
DisplayName: "Volume Vacuum",
|
||||
Description: "Reclaims disk space by removing deleted files from volumes",
|
||||
Weight: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VacuumHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: "vacuum",
|
||||
DisplayName: "Volume Vacuum",
|
||||
Description: "Detect and vacuum volumes with high garbage ratio",
|
||||
Icon: "fas fa-broom",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "vacuum-admin",
|
||||
Title: "Vacuum Admin Config",
|
||||
Description: "Admin-side controls for vacuum detection scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Optional filter to restrict detection.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Filter collections for vacuum detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a comma-separated list of names, wildcards, or regex patterns.",
|
||||
Placeholder: "ALL_COLLECTIONS",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "volume_state",
|
||||
Label: "Volume State Filter",
|
||||
Description: "Filter volumes by state: ALL (default), ACTIVE (writable volumes below size limit), or FULL (read-only volumes above size limit).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
|
||||
Options: []*plugin_pb.ConfigOption{
|
||||
{Value: string(pluginworker.VolumeStateAll), Label: "All Volumes"},
|
||||
{Value: string(pluginworker.VolumeStateActive), Label: "Active (writable)"},
|
||||
{Value: string(pluginworker.VolumeStateFull), Label: "Full (read-only)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only vacuum volumes in matching data centers (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "rack_filter",
|
||||
Label: "Rack Filter",
|
||||
Description: "Only vacuum volumes on matching racks (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all racks",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "node_filter",
|
||||
Label: "Node Filter",
|
||||
Description: "Only vacuum volumes on matching nodes (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all nodes",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"collection_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"volume_state": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: string(pluginworker.VolumeStateAll)},
|
||||
},
|
||||
"data_center_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"rack_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"node_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "vacuum-worker",
|
||||
Title: "Vacuum Worker Config",
|
||||
Description: "Worker-side vacuum thresholds.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "thresholds",
|
||||
Title: "Thresholds",
|
||||
Description: "Detection thresholds and timing constraints.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "garbage_threshold",
|
||||
Label: "Garbage Threshold",
|
||||
Description: "Detect volumes with garbage ratio >= threshold.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 1}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"garbage_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3},
|
||||
},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalMinutes: 17,
|
||||
DetectionTimeoutSeconds: 120,
|
||||
MaxJobsPerDetection: 200,
|
||||
GlobalExecutionConcurrency: 16,
|
||||
PerWorkerExecutionConcurrency: 4,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 10,
|
||||
JobTypeMaxRuntimeSeconds: 1800,
|
||||
ExecutionTimeoutSeconds: 1800,
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"garbage_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VacuumHandler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != "vacuum" {
|
||||
return fmt.Errorf("job type %q is not handled by vacuum worker", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := deriveVacuumConfig(request.GetWorkerConfigValues())
|
||||
collectionFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
|
||||
masters := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
volState := pluginworker.VolumeState(strings.ToUpper(strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "volume_state", string(pluginworker.VolumeStateAll)))))
|
||||
metrics = pluginworker.FilterMetricsByVolumeState(metrics, volState)
|
||||
|
||||
dataCenterFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
rackFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "rack_filter", ""))
|
||||
nodeFilter := strings.TrimSpace(pluginworker.ReadStringConfig(request.GetAdminConfigValues(), "node_filter", ""))
|
||||
|
||||
if dataCenterFilter != "" || rackFilter != "" || nodeFilter != "" {
|
||||
metrics = pluginworker.FilterMetricsByLocation(metrics, dataCenterFilter, rackFilter, nodeFilter)
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
results, err := Detection(metrics, clusterInfo, workerConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if traceErr := emitVacuumDetectionDecisionTrace(sender, metrics, workerConfig, results); traceErr != nil {
|
||||
glog.Warningf("Plugin worker failed to emit vacuum detection trace: %v", traceErr)
|
||||
}
|
||||
|
||||
maxResults := int(request.MaxResults)
|
||||
hasMore := false
|
||||
if maxResults > 0 && len(results) > maxResults {
|
||||
hasMore = true
|
||||
results = results[:maxResults]
|
||||
}
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
|
||||
for _, result := range results {
|
||||
proposal, proposalErr := buildVacuumProposal(result)
|
||||
if proposalErr != nil {
|
||||
glog.Warningf("Plugin worker skip invalid vacuum proposal: %v", proposalErr)
|
||||
continue
|
||||
}
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "vacuum",
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "vacuum",
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func emitVacuumDetectionDecisionTrace(
|
||||
sender pluginworker.DetectionSender,
|
||||
metrics []*workertypes.VolumeHealthMetrics,
|
||||
workerConfig *Config,
|
||||
results []*workertypes.TaskDetectionResult,
|
||||
) error {
|
||||
if sender == nil || workerConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
totalVolumes := len(metrics)
|
||||
|
||||
skippedDueToGarbage := 0
|
||||
for _, metric := range metrics {
|
||||
if metric == nil {
|
||||
continue
|
||||
}
|
||||
if metric.GarbageRatio >= workerConfig.GarbageThreshold {
|
||||
continue
|
||||
}
|
||||
skippedDueToGarbage++
|
||||
}
|
||||
|
||||
summaryMessage := ""
|
||||
summaryStage := "decision_summary"
|
||||
if len(results) == 0 {
|
||||
summaryMessage = fmt.Sprintf(
|
||||
"VACUUM: No tasks created for %d volumes. Threshold=%.2f%%. Skipped: %d (garbage<threshold)",
|
||||
totalVolumes,
|
||||
workerConfig.GarbageThreshold*100,
|
||||
skippedDueToGarbage,
|
||||
)
|
||||
} else {
|
||||
summaryMessage = fmt.Sprintf(
|
||||
"VACUUM: Created %d task(s) from %d volumes. Threshold=%.2f%%",
|
||||
len(results),
|
||||
totalVolumes,
|
||||
workerConfig.GarbageThreshold*100,
|
||||
)
|
||||
}
|
||||
|
||||
if err := sender.SendActivity(pluginworker.BuildDetectorActivity(summaryStage, summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_volumes": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
|
||||
},
|
||||
"selected_tasks": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
|
||||
},
|
||||
"garbage_threshold_percent": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: workerConfig.GarbageThreshold * 100},
|
||||
},
|
||||
"skipped_garbage": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedDueToGarbage)},
|
||||
},
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
limit := 3
|
||||
if len(metrics) < limit {
|
||||
limit = len(metrics)
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
metric := metrics[i]
|
||||
if metric == nil {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"VACUUM: Volume %d: garbage=%.2f%% (need ≥%.2f%%)",
|
||||
metric.VolumeID,
|
||||
metric.GarbageRatio*100,
|
||||
workerConfig.GarbageThreshold*100,
|
||||
)
|
||||
if err := sender.SendActivity(pluginworker.BuildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.VolumeID)},
|
||||
},
|
||||
"garbage_percent": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: metric.GarbageRatio * 100},
|
||||
},
|
||||
"required_garbage_percent": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: workerConfig.GarbageThreshold * 100},
|
||||
},
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *VacuumHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != "vacuum" {
|
||||
return fmt.Errorf("job type %q is not handled by vacuum worker", request.Job.JobType)
|
||||
}
|
||||
|
||||
params, err := decodeVacuumTaskParams(request.Job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return fmt.Errorf("vacuum task source node is required")
|
||||
}
|
||||
|
||||
workerConfig := deriveVacuumConfig(request.GetWorkerConfigValues())
|
||||
if vacuumParams := params.GetVacuumParams(); vacuumParams != nil {
|
||||
if vacuumParams.GarbageThreshold <= 0 {
|
||||
vacuumParams.GarbageThreshold = workerConfig.GarbageThreshold
|
||||
}
|
||||
} else {
|
||||
params.TaskParams = &worker_pb.TaskParams_VacuumParams{
|
||||
VacuumParams: &worker_pb.VacuumTaskParams{
|
||||
GarbageThreshold: workerConfig.GarbageThreshold,
|
||||
BatchSize: defaultVacuumTaskBatchSize,
|
||||
VerifyChecksum: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
servers := make([]string, 0, len(params.Sources))
|
||||
for _, src := range params.Sources {
|
||||
if src != nil && src.Node != "" {
|
||||
servers = append(servers, src.Node)
|
||||
}
|
||||
}
|
||||
task := NewVacuumTask(
|
||||
request.Job.JobId,
|
||||
servers,
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
h.grpcDialOption,
|
||||
)
|
||||
execCtx, execCancel := context.WithCancel(ctx)
|
||||
defer execCancel()
|
||||
task.SetProgressCallback(func(progress float64, stage string) {
|
||||
message := fmt.Sprintf("vacuum progress %.0f%%", progress)
|
||||
if strings.TrimSpace(stage) != "" {
|
||||
message = stage
|
||||
}
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity(stage, message),
|
||||
},
|
||||
}); err != nil {
|
||||
execCancel()
|
||||
}
|
||||
})
|
||||
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: "vacuum job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("assigned", "vacuum job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := task.Execute(execCtx, params); err != nil {
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_FAILED,
|
||||
ProgressPercent: 100,
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
resultSummary := fmt.Sprintf("vacuum completed for volume %d", params.VolumeId)
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
Success: true,
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
|
||||
},
|
||||
"server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: params.Sources[0].Node},
|
||||
},
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *VacuumHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
metrics, activeTopology, _, err := pluginworker.CollectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveVacuumConfig(values map[string]*plugin_pb.ConfigValue) *Config {
|
||||
config := NewDefaultConfig()
|
||||
config.GarbageThreshold = pluginworker.ReadDoubleConfig(values, "garbage_threshold", config.GarbageThreshold)
|
||||
// Match the shell/master vacuum, which applies no volume-age gate (default 0),
|
||||
// but honor an explicit override instead of silently dropping it.
|
||||
config.MinVolumeAgeSeconds = pluginworker.ReadIntConfig(values, "min_volume_age_seconds", 0)
|
||||
return config
|
||||
}
|
||||
|
||||
func buildVacuumProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.JobProposal, error) {
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("task detection result is nil")
|
||||
}
|
||||
if result.TypedParams == nil {
|
||||
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
|
||||
}
|
||||
|
||||
paramsPayload, err := proto.Marshal(result.TypedParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal task params: %w", err)
|
||||
}
|
||||
|
||||
proposalID := strings.TrimSpace(result.TaskID)
|
||||
if proposalID == "" {
|
||||
proposalID = fmt.Sprintf("vacuum-%d-%d", result.VolumeID, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
dedupeKey := fmt.Sprintf("vacuum:%d", result.VolumeID)
|
||||
if result.Collection != "" {
|
||||
dedupeKey = dedupeKey + ":" + result.Collection
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Vacuum volume %d", result.VolumeID)
|
||||
if strings.TrimSpace(result.Server) != "" {
|
||||
summary = summary + " on " + result.Server
|
||||
}
|
||||
|
||||
// Estimate runtime: 5 min/GB (compact + commit + overhead)
|
||||
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
|
||||
estimatedRuntimeSeconds := volumeSizeGB * 5 * 60
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: proposalID,
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: "vacuum",
|
||||
Priority: pluginworker.MapTaskPriority(result.Priority),
|
||||
Summary: summary,
|
||||
Detail: strings.TrimSpace(result.Reason),
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {
|
||||
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
|
||||
},
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
|
||||
},
|
||||
"server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Server},
|
||||
},
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
|
||||
},
|
||||
"estimated_runtime_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "vacuum",
|
||||
"volume_id": fmt.Sprintf("%d", result.VolumeID),
|
||||
"collection": result.Collection,
|
||||
"source_node": result.Server,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeVacuumTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
|
||||
if job == nil {
|
||||
return nil, fmt.Errorf("job spec is nil")
|
||||
}
|
||||
|
||||
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal task_params_pb: %w", err)
|
||||
}
|
||||
if params.TaskId == "" {
|
||||
params.TaskId = job.JobId
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
volumeID := pluginworker.ReadUint32Config(job.Parameters, "volume_id", 0)
|
||||
server := pluginworker.ReadStringConfig(job.Parameters, "server", "")
|
||||
collection := pluginworker.ReadStringConfig(job.Parameters, "collection", "")
|
||||
if volumeID == 0 {
|
||||
return nil, fmt.Errorf("missing volume_id in job parameters")
|
||||
}
|
||||
if strings.TrimSpace(server) == "" {
|
||||
return nil, fmt.Errorf("missing server in job parameters")
|
||||
}
|
||||
|
||||
return &worker_pb.TaskParams{
|
||||
TaskId: job.JobId,
|
||||
VolumeId: volumeID,
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: server,
|
||||
VolumeId: volumeID,
|
||||
},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_VacuumParams{
|
||||
VacuumParams: &worker_pb.VacuumTaskParams{
|
||||
GarbageThreshold: 0.3,
|
||||
BatchSize: defaultVacuumTaskBatchSize,
|
||||
VerifyChecksum: true,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
373
weed/worker/tasks/vacuum/plugin_handler_test.go
Normal file
373
weed/worker/tasks/vacuum/plugin_handler_test.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestDecodeVacuumTaskParamsFromPayload(t *testing.T) {
|
||||
expected := &worker_pb.TaskParams{
|
||||
TaskId: "task-1",
|
||||
VolumeId: 42,
|
||||
Collection: "photos",
|
||||
Sources: []*worker_pb.TaskSource{
|
||||
{
|
||||
Node: "10.0.0.1:8080",
|
||||
VolumeId: 42,
|
||||
},
|
||||
},
|
||||
TaskParams: &worker_pb.TaskParams_VacuumParams{
|
||||
VacuumParams: &worker_pb.VacuumTaskParams{
|
||||
GarbageThreshold: 0.33,
|
||||
BatchSize: 500,
|
||||
VerifyChecksum: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := proto.Marshal(expected)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-from-admin",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
actual, err := decodeVacuumTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeVacuumTaskParams() err = %v", err)
|
||||
}
|
||||
if !proto.Equal(expected, actual) {
|
||||
t.Fatalf("decoded params mismatch\nexpected: %+v\nactual: %+v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeVacuumTaskParamsFallback(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-2",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
|
||||
"server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "127.0.0.1:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "videos"}},
|
||||
},
|
||||
}
|
||||
|
||||
params, err := decodeVacuumTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeVacuumTaskParams() err = %v", err)
|
||||
}
|
||||
if params.TaskId != "job-2" || params.VolumeId != 7 || params.Collection != "videos" {
|
||||
t.Fatalf("unexpected basic params: %+v", params)
|
||||
}
|
||||
if len(params.Sources) != 1 || params.Sources[0].Node != "127.0.0.1:8080" {
|
||||
t.Fatalf("unexpected sources: %+v", params.Sources)
|
||||
}
|
||||
if params.GetVacuumParams() == nil {
|
||||
t.Fatalf("expected fallback vacuum params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveVacuumConfigAllowsZeroValues(t *testing.T) {
|
||||
values := map[string]*plugin_pb.ConfigValue{
|
||||
"garbage_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := deriveVacuumConfig(values)
|
||||
if cfg.GarbageThreshold != 0 {
|
||||
t.Fatalf("expected garbage_threshold 0, got %v", cfg.GarbageThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterAddressCandidates(t *testing.T) {
|
||||
candidates := pluginworker.MasterAddressCandidates("localhost:9333")
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d: %v", len(candidates), candidates)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
seen[candidate] = true
|
||||
}
|
||||
if !seen["localhost:9333"] {
|
||||
t.Fatalf("expected original address in candidates: %v", candidates)
|
||||
}
|
||||
if !seen["localhost:19333"] {
|
||||
t.Fatalf("expected grpc address in candidates: %v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipDetectionByInterval(t *testing.T) {
|
||||
if pluginworker.ShouldSkipDetectionByInterval(nil, 10) {
|
||||
t.Fatalf("expected false when timestamp is nil")
|
||||
}
|
||||
if pluginworker.ShouldSkipDetectionByInterval(timestamppb.Now(), 0) {
|
||||
t.Fatalf("expected false when min interval is zero")
|
||||
}
|
||||
|
||||
recent := timestamppb.New(time.Now().Add(-5 * time.Second))
|
||||
if !pluginworker.ShouldSkipDetectionByInterval(recent, 10) {
|
||||
t.Fatalf("expected true for recent successful run")
|
||||
}
|
||||
|
||||
old := timestamppb.New(time.Now().Add(-30 * time.Second))
|
||||
if pluginworker.ShouldSkipDetectionByInterval(old, 10) {
|
||||
t.Fatalf("expected false for old successful run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumHandlerRejectsUnsupportedJobType(t *testing.T) {
|
||||
handler := NewVacuumHandler(nil, 0)
|
||||
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
|
||||
JobType: "balance",
|
||||
}, noopDetectionSender{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected detect job type mismatch error")
|
||||
}
|
||||
|
||||
err = handler.Execute(context.Background(), &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{JobId: "job-1", JobType: "balance"},
|
||||
}, noopExecutionSender{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected execute job type mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExecutorActivity(t *testing.T) {
|
||||
activity := pluginworker.BuildExecutorActivity("running", "vacuum in progress")
|
||||
if activity == nil {
|
||||
t.Fatalf("expected non-nil activity")
|
||||
}
|
||||
if activity.Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_EXECUTOR {
|
||||
t.Fatalf("unexpected source: %v", activity.Source)
|
||||
}
|
||||
if activity.Stage != "running" {
|
||||
t.Fatalf("unexpected stage: %q", activity.Stage)
|
||||
}
|
||||
if activity.Message != "vacuum in progress" {
|
||||
t.Fatalf("unexpected message: %q", activity.Message)
|
||||
}
|
||||
if activity.CreatedAt == nil {
|
||||
t.Fatalf("expected created_at timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitVacuumDetectionDecisionTraceNoTasks(t *testing.T) {
|
||||
sender := &recordingDetectionSender{}
|
||||
config := NewDefaultConfig()
|
||||
config.GarbageThreshold = 0.3
|
||||
config.MinVolumeAgeSeconds = 0
|
||||
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{
|
||||
VolumeID: 17,
|
||||
GarbageRatio: 0,
|
||||
Age: 218*time.Hour + 23*time.Minute,
|
||||
},
|
||||
{
|
||||
VolumeID: 16,
|
||||
GarbageRatio: 0,
|
||||
Age: 218*time.Hour + 22*time.Minute,
|
||||
},
|
||||
{
|
||||
VolumeID: 6,
|
||||
GarbageRatio: 0,
|
||||
Age: 90*time.Hour + 42*time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
if err := emitVacuumDetectionDecisionTrace(sender, metrics, config, nil); err != nil {
|
||||
t.Fatalf("emitVacuumDetectionDecisionTrace error: %v", err)
|
||||
}
|
||||
if len(sender.events) < 4 {
|
||||
t.Fatalf("expected at least 4 detection events, got %d", len(sender.events))
|
||||
}
|
||||
|
||||
if sender.events[0].Source != plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR {
|
||||
t.Fatalf("expected detector source, got %v", sender.events[0].Source)
|
||||
}
|
||||
if !strings.Contains(sender.events[0].Message, "VACUUM: No tasks created for 3 volumes") {
|
||||
t.Fatalf("unexpected summary message: %q", sender.events[0].Message)
|
||||
}
|
||||
if !strings.Contains(sender.events[1].Message, "VACUUM: Volume 17: garbage=0.00%") {
|
||||
t.Fatalf("unexpected first detail message: %q", sender.events[1].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumDescriptorHasNewFields(t *testing.T) {
|
||||
handler := NewVacuumHandler(nil, 2)
|
||||
desc := handler.Descriptor()
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
adminForm := desc.AdminConfigForm
|
||||
if adminForm == nil {
|
||||
t.Fatal("admin config form is nil")
|
||||
}
|
||||
if len(adminForm.Sections) == 0 {
|
||||
t.Fatal("admin config form has no sections")
|
||||
}
|
||||
|
||||
scopeSection := adminForm.Sections[0]
|
||||
fieldNames := make(map[string]*plugin_pb.ConfigField)
|
||||
for _, f := range scopeSection.Fields {
|
||||
fieldNames[f.Name] = f
|
||||
}
|
||||
|
||||
requiredFields := []string{
|
||||
"collection_filter",
|
||||
"volume_state",
|
||||
"data_center_filter",
|
||||
"rack_filter",
|
||||
"node_filter",
|
||||
}
|
||||
for _, name := range requiredFields {
|
||||
if _, ok := fieldNames[name]; !ok {
|
||||
t.Errorf("missing field %q in admin config scope section", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify volume_state is an enum with correct options.
|
||||
vsField := fieldNames["volume_state"]
|
||||
if vsField.FieldType != plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM {
|
||||
t.Errorf("volume_state field type = %v, want ENUM", vsField.FieldType)
|
||||
}
|
||||
if len(vsField.Options) != 3 {
|
||||
t.Errorf("volume_state options count = %d, want 3", len(vsField.Options))
|
||||
}
|
||||
|
||||
// Verify default values exist for new fields.
|
||||
defaultKeys := []string{"volume_state", "data_center_filter", "rack_filter", "node_filter"}
|
||||
for _, key := range defaultKeys {
|
||||
if _, ok := adminForm.DefaultValues[key]; !ok {
|
||||
t.Errorf("missing default value for %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify volume_state default is ALL.
|
||||
vsDefault := adminForm.DefaultValues["volume_state"]
|
||||
if sv, ok := vsDefault.Kind.(*plugin_pb.ConfigValue_StringValue); !ok || sv.StringValue != "ALL" {
|
||||
t.Errorf("volume_state default = %v, want ALL", vsDefault)
|
||||
}
|
||||
|
||||
// Verify collection_filter description mentions ALL_COLLECTIONS.
|
||||
cfField := fieldNames["collection_filter"]
|
||||
if cfField.Placeholder != "ALL_COLLECTIONS" {
|
||||
t.Errorf("collection_filter placeholder = %q, want ALL_COLLECTIONS", cfField.Placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumFiltersVolumeState(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||
{VolumeID: 2, FullnessRatio: 1.5}, // full
|
||||
{VolumeID: 3, FullnessRatio: 0.9}, // active
|
||||
{VolumeID: 4, FullnessRatio: 1.01}, // full (at threshold)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state pluginworker.VolumeState
|
||||
wantIDs []uint32
|
||||
}{
|
||||
{"ALL returns all", pluginworker.VolumeStateAll, []uint32{1, 2, 3, 4}},
|
||||
{"ACTIVE returns writable", pluginworker.VolumeStateActive, []uint32{1, 3}},
|
||||
{"FULL returns read-only", pluginworker.VolumeStateFull, []uint32{2, 4}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := pluginworker.FilterMetricsByVolumeState(metrics, tt.state)
|
||||
if len(filtered) != len(tt.wantIDs) {
|
||||
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
|
||||
}
|
||||
for i, m := range filtered {
|
||||
if m.VolumeID != tt.wantIDs[i] {
|
||||
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumFiltersLocation(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, DataCenter: "dc1", Rack: "r1", Server: "s1"},
|
||||
{VolumeID: 2, DataCenter: "dc1", Rack: "r2", Server: "s2"},
|
||||
{VolumeID: 3, DataCenter: "dc2", Rack: "r1", Server: "s3"},
|
||||
{VolumeID: 4, DataCenter: "dc2", Rack: "r3", Server: "s4"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dc string
|
||||
rack string
|
||||
node string
|
||||
wantIDs []uint32
|
||||
}{
|
||||
{"dc filter", "dc1", "", "", []uint32{1, 2}},
|
||||
{"rack filter", "", "r1", "", []uint32{1, 3}},
|
||||
{"node filter", "", "", "s2,s4", []uint32{2, 4}},
|
||||
{"dc + rack", "dc1", "r1", "", []uint32{1}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := pluginworker.FilterMetricsByLocation(metrics, tt.dc, tt.rack, tt.node)
|
||||
if len(filtered) != len(tt.wantIDs) {
|
||||
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
|
||||
}
|
||||
for i, m := range filtered {
|
||||
if m.VolumeID != tt.wantIDs[i] {
|
||||
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type noopDetectionSender struct{}
|
||||
|
||||
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
|
||||
func (noopDetectionSender) SendComplete(*plugin_pb.DetectionComplete) error { return nil }
|
||||
func (noopDetectionSender) SendActivity(*plugin_pb.ActivityEvent) error { return nil }
|
||||
|
||||
type noopExecutionSender struct{}
|
||||
|
||||
func (noopExecutionSender) SendProgress(*plugin_pb.JobProgressUpdate) error { return nil }
|
||||
func (noopExecutionSender) SendCompleted(*plugin_pb.JobCompleted) error { return nil }
|
||||
|
||||
type recordingDetectionSender struct {
|
||||
proposals *plugin_pb.DetectionProposals
|
||||
complete *plugin_pb.DetectionComplete
|
||||
events []*plugin_pb.ActivityEvent
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendProposals(proposals *plugin_pb.DetectionProposals) error {
|
||||
r.proposals = proposals
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendComplete(complete *plugin_pb.DetectionComplete) error {
|
||||
r.complete = complete
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingDetectionSender) SendActivity(event *plugin_pb.ActivityEvent) error {
|
||||
if event != nil {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
98
weed/worker/tasks/vacuum/register.go
Normal file
98
weed/worker/tasks/vacuum/register.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Global variable to hold the task definition for configuration updates
|
||||
var globalTaskDef *base.TaskDefinition
|
||||
|
||||
// Auto-register this task when the package is imported
|
||||
func init() {
|
||||
RegisterVacuumTask()
|
||||
|
||||
// Register config updater
|
||||
tasks.AutoRegisterConfigUpdater(types.TaskTypeVacuum, UpdateConfigFromPersistence)
|
||||
}
|
||||
|
||||
// RegisterVacuumTask registers the vacuum task with the new architecture
|
||||
func RegisterVacuumTask() {
|
||||
// Create configuration instance
|
||||
config := NewDefaultConfig()
|
||||
|
||||
// Create shared gRPC dial option using TLS configuration
|
||||
dialOpt := security.LoadClientTLS(util.GetViper(), "grpc.worker")
|
||||
|
||||
// Create complete task definition
|
||||
taskDef := &base.TaskDefinition{
|
||||
Type: types.TaskTypeVacuum,
|
||||
Name: "vacuum",
|
||||
DisplayName: "Volume Vacuum",
|
||||
Description: "Reclaims disk space by removing deleted files from volumes",
|
||||
Icon: "fas fa-broom text-primary",
|
||||
Capabilities: []string{"vacuum", "storage"},
|
||||
|
||||
Config: config,
|
||||
ConfigSpec: GetConfigSpec(),
|
||||
CreateTask: func(params *worker_pb.TaskParams) (types.Task, error) {
|
||||
if params == nil {
|
||||
return nil, fmt.Errorf("task parameters are required")
|
||||
}
|
||||
if len(params.Sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required for vacuum task")
|
||||
}
|
||||
servers := make([]string, 0, len(params.Sources))
|
||||
for _, src := range params.Sources {
|
||||
if src != nil && src.Node != "" {
|
||||
servers = append(servers, src.Node)
|
||||
}
|
||||
}
|
||||
return NewVacuumTask(
|
||||
fmt.Sprintf("vacuum-%d", params.VolumeId),
|
||||
servers,
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
dialOpt,
|
||||
), nil
|
||||
},
|
||||
DetectionFunc: Detection,
|
||||
ScanInterval: 2 * time.Hour,
|
||||
SchedulingFunc: Scheduling,
|
||||
MaxConcurrent: 2,
|
||||
RepeatInterval: 7 * 24 * time.Hour,
|
||||
}
|
||||
|
||||
// Store task definition globally for configuration updates
|
||||
globalTaskDef = taskDef
|
||||
|
||||
// Register everything with a single function call!
|
||||
base.RegisterTask(taskDef)
|
||||
}
|
||||
|
||||
// UpdateConfigFromPersistence updates the vacuum configuration from persistence
|
||||
func UpdateConfigFromPersistence(configPersistence interface{}) error {
|
||||
if globalTaskDef == nil {
|
||||
return fmt.Errorf("vacuum task not registered")
|
||||
}
|
||||
|
||||
// Load configuration from persistence
|
||||
newConfig := LoadConfigFromPersistence(configPersistence)
|
||||
if newConfig == nil {
|
||||
return fmt.Errorf("failed to load configuration from persistence")
|
||||
}
|
||||
|
||||
// Update the task definition's config
|
||||
globalTaskDef.Config = newConfig
|
||||
|
||||
glog.V(1).Infof("Updated vacuum task configuration from persistence")
|
||||
return nil
|
||||
}
|
||||
37
weed/worker/tasks/vacuum/scheduling.go
Normal file
37
weed/worker/tasks/vacuum/scheduling.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/base"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
)
|
||||
|
||||
// Scheduling implements the scheduling logic for vacuum tasks
|
||||
func Scheduling(task *types.TaskInput, runningTasks []*types.TaskInput, availableWorkers []*types.WorkerData, config base.TaskConfig) bool {
|
||||
vacuumConfig := config.(*Config)
|
||||
|
||||
// Count running vacuum tasks
|
||||
runningVacuumCount := 0
|
||||
for _, runningTask := range runningTasks {
|
||||
if runningTask.Type == types.TaskTypeVacuum {
|
||||
runningVacuumCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrency limit
|
||||
if runningVacuumCount >= vacuumConfig.MaxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for available workers with vacuum capability
|
||||
for _, worker := range availableWorkers {
|
||||
if worker.CurrentLoad < worker.MaxConcurrent {
|
||||
for _, capability := range worker.Capabilities {
|
||||
if capability == types.TaskTypeVacuum {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
444
weed/worker/tasks/vacuum/vacuum_task.go
Normal file
444
weed/worker/tasks/vacuum/vacuum_task.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package vacuum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/operation"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// VacuumTask implements the Task interface.
|
||||
//
|
||||
// One task covers all replicas of a volume so behavior matches the master
|
||||
// built-in vacuum (see topology.Topology.vacuumOneVolumeId): Check across
|
||||
// every replica → filter to those whose garbage ratio meets the threshold
|
||||
// → Compact/Commit/Cleanup that subset. Treating one replica per task (the
|
||||
// prior behavior) drops the other N-1 replicas because the dispatcher
|
||||
// gates duplicate tasks per volume via ActiveTopology.HasAnyTask.
|
||||
type VacuumTask struct {
|
||||
*base.BaseTask
|
||||
servers []string
|
||||
volumeID uint32
|
||||
collection string
|
||||
garbageThreshold float64
|
||||
progress float64
|
||||
grpcDialOption grpc.DialOption
|
||||
volumeSize uint64
|
||||
vacuumTargets []string // populated by checkVacuumEligibility — subset of servers that pass the per-replica garbage re-check and proceed to Compact/Commit/Cleanup
|
||||
}
|
||||
|
||||
// NewVacuumTask creates a new unified vacuum task instance covering every
|
||||
// replica server reported by the dispatcher.
|
||||
func NewVacuumTask(id string, servers []string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *VacuumTask {
|
||||
deduped := dedupePreserveOrder(servers)
|
||||
return &VacuumTask{
|
||||
BaseTask: base.NewBaseTask(id, types.TaskTypeVacuum),
|
||||
servers: deduped,
|
||||
volumeID: volumeID,
|
||||
collection: collection,
|
||||
garbageThreshold: 0.3, // Default 30% threshold
|
||||
grpcDialOption: grpcDialOption,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute implements the UnifiedTask interface
|
||||
func (t *VacuumTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return fmt.Errorf("task parameters are required")
|
||||
}
|
||||
|
||||
vacuumParams := params.GetVacuumParams()
|
||||
if vacuumParams == nil {
|
||||
return fmt.Errorf("vacuum parameters are required")
|
||||
}
|
||||
|
||||
t.garbageThreshold = vacuumParams.GarbageThreshold
|
||||
t.volumeSize = params.VolumeSize
|
||||
|
||||
t.GetLogger().WithFields(map[string]interface{}{
|
||||
"volume_id": t.volumeID,
|
||||
"servers": t.servers,
|
||||
"collection": t.collection,
|
||||
"garbage_threshold": t.garbageThreshold,
|
||||
}).Info("Starting vacuum task")
|
||||
|
||||
if len(t.servers) == 0 {
|
||||
return fmt.Errorf("no source servers configured for vacuum task")
|
||||
}
|
||||
|
||||
// Step 1: Check vacuum eligibility for each replica. Mirrors
|
||||
// topology.batchVacuumVolumeCheck — only replicas whose garbage is at
|
||||
// or above the threshold proceed to Compact/Commit/Cleanup.
|
||||
t.ReportProgress(10.0)
|
||||
t.GetLogger().Info("Checking volume status")
|
||||
targets, currentGarbageRatios, err := t.checkVacuumEligibility(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check vacuum eligibility: %w", err)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
t.GetLogger().WithFields(map[string]interface{}{
|
||||
"garbage_ratios": currentGarbageRatios,
|
||||
"required_threshold": t.garbageThreshold,
|
||||
}).Info("No replica meets vacuum criteria, skipping")
|
||||
t.ReportProgress(100.0)
|
||||
return nil
|
||||
}
|
||||
t.vacuumTargets = targets
|
||||
|
||||
// Step 2: Perform vacuum (compact + commit + cleanup) across every
|
||||
// target replica.
|
||||
t.ReportProgress(50.0)
|
||||
t.GetLogger().WithFields(map[string]interface{}{
|
||||
"vacuum_targets": targets,
|
||||
"garbage_ratios": currentGarbageRatios,
|
||||
"threshold": t.garbageThreshold,
|
||||
}).Info("Performing vacuum operation")
|
||||
|
||||
if err := t.performVacuum(ctx); err != nil {
|
||||
return fmt.Errorf("failed to perform vacuum: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Verify vacuum results on each target replica.
|
||||
t.ReportProgress(90.0)
|
||||
t.GetLogger().Info("Verifying vacuum results")
|
||||
if err := t.verifyVacuumResults(ctx); err != nil {
|
||||
glog.Warningf("Vacuum verification failed: %v", err)
|
||||
// Don't fail the task - vacuum operation itself succeeded
|
||||
}
|
||||
|
||||
t.ReportProgress(100.0)
|
||||
glog.Infof("Vacuum task completed successfully: volume %d on %v (garbage ratios %v)",
|
||||
t.volumeID, targets, currentGarbageRatios)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate implements the UnifiedTask interface
|
||||
func (t *VacuumTask) Validate(params *worker_pb.TaskParams) error {
|
||||
if params == nil {
|
||||
return fmt.Errorf("task parameters are required")
|
||||
}
|
||||
|
||||
vacuumParams := params.GetVacuumParams()
|
||||
if vacuumParams == nil {
|
||||
return fmt.Errorf("vacuum parameters are required")
|
||||
}
|
||||
|
||||
if params.VolumeId != t.volumeID {
|
||||
return fmt.Errorf("volume ID mismatch: expected %d, got %d", t.volumeID, params.VolumeId)
|
||||
}
|
||||
|
||||
// Every server the task was created with must appear in the params'
|
||||
// Sources list. The dispatcher fills Sources from the detection-time
|
||||
// replica set, so a mismatch means the worker received stale routing.
|
||||
sourceSet := make(map[string]struct{}, len(params.Sources))
|
||||
for _, source := range params.Sources {
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
sourceSet[source.Node] = struct{}{}
|
||||
}
|
||||
for _, server := range t.servers {
|
||||
if _, ok := sourceSet[server]; !ok {
|
||||
return fmt.Errorf("task server %s not present in params.Sources", server)
|
||||
}
|
||||
}
|
||||
|
||||
if vacuumParams.GarbageThreshold < 0 || vacuumParams.GarbageThreshold > 1.0 {
|
||||
return fmt.Errorf("invalid garbage threshold: %f (must be between 0.0 and 1.0)", vacuumParams.GarbageThreshold)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateTime implements the UnifiedTask interface
|
||||
func (t *VacuumTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
|
||||
// Basic estimate based on simulated steps
|
||||
return 14 * time.Second // Sum of all step durations
|
||||
}
|
||||
|
||||
// GetProgress returns current progress
|
||||
func (t *VacuumTask) GetProgress() float64 {
|
||||
return t.progress
|
||||
}
|
||||
|
||||
// vacuumTimeout returns a dynamic timeout scaled by volume size, matching the
|
||||
// topology vacuum approach. base is the per-GB multiplier (e.g. 1 minute for
|
||||
// check, 3 minutes for compact).
|
||||
func (t *VacuumTask) vacuumTimeout(base time.Duration) time.Duration {
|
||||
if t.volumeSize == 0 {
|
||||
glog.V(1).Infof("volume %d has no size metric, using minimum timeout", t.volumeID)
|
||||
}
|
||||
sizeGB := int64(t.volumeSize/1024/1024/1024) + 1
|
||||
return base * time.Duration(sizeGB)
|
||||
}
|
||||
|
||||
// Helper methods for real vacuum operations
|
||||
|
||||
// checkVacuumEligibility queries every replica's current garbage ratio.
|
||||
// Mirrors topology.batchVacuumVolumeCheck's all-or-nothing contract: if
|
||||
// any replica's check fails (unreachable, RPC error, etc.) the entire
|
||||
// task aborts. Vacuuming only the replicas that responded while
|
||||
// silently skipping unreachable ones would compact the responders'
|
||||
// garbage but leave the unreachable replica still carrying it,
|
||||
// producing divergence the moment that replica comes back.
|
||||
//
|
||||
// Returns the subset of servers whose garbage is at or above the
|
||||
// configured threshold, alongside a per-server ratio map for logging.
|
||||
func (t *VacuumTask) checkVacuumEligibility(ctx context.Context) ([]string, map[string]float64, error) {
|
||||
ratios := make(map[string]float64, len(t.servers))
|
||||
for _, server := range t.servers {
|
||||
ratio, err := t.checkOneVacuumEligibility(ctx, server)
|
||||
if err != nil {
|
||||
return nil, ratios, fmt.Errorf("vacuum check on %s for volume %d: %w (aborting; refusing to vacuum subset of replicas)", server, t.volumeID, err)
|
||||
}
|
||||
ratios[server] = ratio
|
||||
glog.V(1).Infof("Volume %d on %s garbage ratio: %.2f%%, threshold: %.2f%%",
|
||||
t.volumeID, server, ratio*100, t.garbageThreshold*100)
|
||||
}
|
||||
|
||||
eligible := make([]string, 0, len(ratios))
|
||||
for _, server := range t.servers {
|
||||
if ratios[server] >= t.garbageThreshold {
|
||||
eligible = append(eligible, server)
|
||||
}
|
||||
}
|
||||
return eligible, ratios, nil
|
||||
}
|
||||
|
||||
func (t *VacuumTask) checkOneVacuumEligibility(ctx context.Context, server string) (float64, error) {
|
||||
var garbageRatio float64
|
||||
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
|
||||
defer cancel()
|
||||
resp, err := client.VacuumVolumeCheck(checkCtx, &volume_server_pb.VacuumVolumeCheckRequest{
|
||||
VolumeId: t.volumeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check volume vacuum status: %v", err)
|
||||
}
|
||||
garbageRatio = resp.GarbageRatio
|
||||
return nil
|
||||
})
|
||||
return garbageRatio, err
|
||||
}
|
||||
|
||||
// performVacuum runs the three-phase vacuum protocol that mirrors
|
||||
// master built-in vacuum (topology.vacuumOneVolumeId):
|
||||
//
|
||||
// Phase 1 (Compact): build the new .cpd/.cpx files on every target.
|
||||
// If any replica fails, roll back by Cleanup'ing the .cp* temp files
|
||||
// on every target and abort — no replica has yet swapped its active
|
||||
// files, so no replica is committed.
|
||||
//
|
||||
// Phase 2 (Commit): swap each target's active files with its .cp*
|
||||
// files. Best-effort, matching batchVacuumVolumeCommit: per-replica
|
||||
// errors are logged and surfaced together, but once any replica has
|
||||
// swapped there is no clean rollback for the others, so we do not
|
||||
// retry or undo. An operator must reconcile a partial commit
|
||||
// failure.
|
||||
//
|
||||
// Phase 3 (Mark Writable): re-notify the master per replica so the
|
||||
// volume re-enters the writable set, the worker analog of
|
||||
// batchVacuumVolumeCommit's per-replica SetVolumeAvailable. Skipped
|
||||
// when a replica came back read-only. Best-effort; never fails the
|
||||
// task.
|
||||
//
|
||||
// Interleaving Compact→Commit→Cleanup per replica (the prior behavior)
|
||||
// could leave a committed first replica beside an uncompacted second
|
||||
// replica when Compact on the second failed — replica divergence with
|
||||
// no automatic recovery.
|
||||
func (t *VacuumTask) performVacuum(ctx context.Context) error {
|
||||
// Phase 1: Compact all targets.
|
||||
for _, server := range t.vacuumTargets {
|
||||
if err := t.compactOne(ctx, server); err != nil {
|
||||
t.cleanupAll(ctx)
|
||||
return fmt.Errorf("vacuum compact on %s: %w", server, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Commit all targets, tracking whether any replica is still
|
||||
// read-only after the swap.
|
||||
var commitErrors []error
|
||||
anyReadOnly := false
|
||||
for _, server := range t.vacuumTargets {
|
||||
resp, err := t.commitOne(ctx, server)
|
||||
if err != nil {
|
||||
glog.Errorf("vacuum commit on %s for volume %d: %v", server, t.volumeID, err)
|
||||
commitErrors = append(commitErrors, fmt.Errorf("%s: %w", server, err))
|
||||
continue
|
||||
}
|
||||
if resp.GetIsReadOnly() {
|
||||
anyReadOnly = true
|
||||
}
|
||||
}
|
||||
if len(commitErrors) > 0 {
|
||||
return fmt.Errorf("vacuum commit failed on %d/%d replicas: %v",
|
||||
len(commitErrors), len(t.vacuumTargets), commitErrors)
|
||||
}
|
||||
|
||||
// Phase 3: re-notify the master so the volume re-enters the writable
|
||||
// set. The worker's only lever is VolumeMarkWritable, which clears the
|
||||
// read-only flag and runs notifyMasterVolumeReadonly(false). Gate on
|
||||
// the commit's IsReadOnly exactly as SetVolumeAvailable does: a replica
|
||||
// still read-only (operator-set, EIO-quarantined, or disk-space-low)
|
||||
// must stay out, and recovers on its own via the next ReadOnly=false
|
||||
// heartbeat — force-clearing the flag here would override that. The
|
||||
// worker is not told the master's size limit, so the isFullCapacity
|
||||
// guard is left to the next capacity heartbeat. Best-effort: the vacuum
|
||||
// itself already succeeded.
|
||||
if anyReadOnly {
|
||||
glog.V(0).Infof("post-vacuum: volume %d still read-only on a replica, leaving it out of writables", t.volumeID)
|
||||
return nil
|
||||
}
|
||||
for _, server := range t.vacuumTargets {
|
||||
if err := t.markWritableOne(ctx, server); err != nil {
|
||||
glog.Warningf("post-vacuum mark writable on %s for volume %d: %v", server, t.volumeID, err)
|
||||
continue
|
||||
}
|
||||
glog.V(0).Infof("post-vacuum marked volume %d writable on %s", t.volumeID, server)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *VacuumTask) compactOne(ctx context.Context, server string) error {
|
||||
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
t.GetLogger().Info("Compacting volume on %s", server)
|
||||
compactCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(3*time.Minute))
|
||||
defer cancel()
|
||||
stream, err := client.VacuumVolumeCompact(compactCtx, &volume_server_pb.VacuumVolumeCompactRequest{
|
||||
VolumeId: t.volumeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum compact start: %v", err)
|
||||
}
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("vacuum compact stream: %v", recvErr)
|
||||
}
|
||||
glog.V(2).Infof("Volume %d on %s compact progress: %d bytes", t.volumeID, server, resp.ProcessedBytes)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (t *VacuumTask) commitOne(ctx context.Context, server string) (*volume_server_pb.VacuumVolumeCommitResponse, error) {
|
||||
var resp *volume_server_pb.VacuumVolumeCommitResponse
|
||||
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
t.GetLogger().Info("Committing vacuum on %s", server)
|
||||
commitCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
|
||||
defer cancel()
|
||||
var err error
|
||||
resp, err = client.VacuumVolumeCommit(commitCtx, &volume_server_pb.VacuumVolumeCommitRequest{
|
||||
VolumeId: t.volumeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum commit: %v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (t *VacuumTask) cleanupOne(ctx context.Context, server string) error {
|
||||
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
cleanupCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
|
||||
defer cancel()
|
||||
_, err := client.VacuumVolumeCleanup(cleanupCtx, &volume_server_pb.VacuumVolumeCleanupRequest{
|
||||
VolumeId: t.volumeID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (t *VacuumTask) markWritableOne(ctx context.Context, server string) error {
|
||||
return operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
// VolumeMarkWritable is a metadata RPC (reopen idx + flags +
|
||||
// notifyMasterVolumeReadonly heartbeat) — millisecond-scale and
|
||||
// independent of volume size. A flat 1m cap prevents an
|
||||
// unresponsive replica from blocking Phase 3 for hours on a
|
||||
// TB-scale volume where vacuumTimeout() would balloon.
|
||||
markCtx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
_, err := client.VolumeMarkWritable(markCtx, &volume_server_pb.VolumeMarkWritableRequest{
|
||||
VolumeId: t.volumeID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupAll removes the .cpd/.cpx/.cpldb temp files on every target.
|
||||
// Used to roll back when Compact fails on one replica after others
|
||||
// have already created their temp files. Per-target failures are
|
||||
// logged but never bubble up — the rollback is best-effort.
|
||||
func (t *VacuumTask) cleanupAll(ctx context.Context) {
|
||||
for _, server := range t.vacuumTargets {
|
||||
if err := t.cleanupOne(ctx, server); err != nil {
|
||||
glog.Warningf("rollback cleanup on %s for volume %d: %v", server, t.volumeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyVacuumResults checks each target replica's post-vacuum garbage
|
||||
// ratio. Failures are logged at WARN — the task does not fail because the
|
||||
// vacuum itself already succeeded.
|
||||
func (t *VacuumTask) verifyVacuumResults(ctx context.Context) error {
|
||||
for _, server := range t.vacuumTargets {
|
||||
err := operation.WithVolumeServerClient(false, pb.ServerAddress(server), t.grpcDialOption,
|
||||
func(client volume_server_pb.VolumeServerClient) error {
|
||||
verifyCtx, cancel := context.WithTimeout(ctx, t.vacuumTimeout(time.Minute))
|
||||
defer cancel()
|
||||
resp, err := client.VacuumVolumeCheck(verifyCtx, &volume_server_pb.VacuumVolumeCheckRequest{
|
||||
VolumeId: t.volumeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify vacuum results: %v", err)
|
||||
}
|
||||
glog.V(1).Infof("Volume %d on %s post-vacuum garbage ratio: %.2f%%",
|
||||
t.volumeID, server, resp.GarbageRatio*100)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
glog.Warningf("post-vacuum verify on %s: %v", server, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dedupePreserveOrder returns servers with duplicates removed, keeping the
|
||||
// first occurrence's position. Detection sometimes hands the same node
|
||||
// address in multiple Sources (e.g. EC variants); we coalesce them so each
|
||||
// physical replica is vacuumed exactly once.
|
||||
func dedupePreserveOrder(servers []string) []string {
|
||||
seen := make(map[string]struct{}, len(servers))
|
||||
out := make([]string, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user