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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user