mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-18 12:35:52 +00:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f0524d814 | ||
|
|
2749d3f6a1 | ||
|
|
f48896c7e1 | ||
|
|
61d6f2608e | ||
|
|
8094c5ba85 | ||
|
|
25121f5548 | ||
|
|
6df5009fae | ||
|
|
23696fe35d | ||
|
|
6b2b442450 | ||
|
|
a00eddb525 | ||
|
|
e24630251c | ||
|
|
0afc675a55 | ||
|
|
b868980260 | ||
|
|
5f19e3259f | ||
|
|
d9d6707401 | ||
|
|
8cde3d4486 | ||
|
|
47799a5b4f | ||
|
|
c9100a7213 | ||
|
|
1f2014568f | ||
|
|
a838661b83 | ||
|
|
c4d642b8aa | ||
|
|
5acb4578ab | ||
|
|
2f51a94416 | ||
|
|
6fc0489dd8 | ||
|
|
baae672b6f | ||
|
|
89ccb6d825 | ||
|
|
f48725a31d | ||
|
|
8056b702ba | ||
|
|
47ddf05d95 | ||
|
|
00ce1c6eba | ||
|
|
577a8459c9 | ||
|
|
34fe289f32 | ||
|
|
729df9c375 | ||
|
|
f3c5ba3cd6 | ||
|
|
3fe5a7d761 | ||
|
|
a6774f0e01 | ||
|
|
0443b66a75 | ||
|
|
0e570d6a8f | ||
|
|
146a090754 | ||
|
|
bfd0d5c084 | ||
|
|
92a76fc1a2 | ||
|
|
b665c329bc | ||
|
|
e4a77b8b16 | ||
|
|
013362d2d3 | ||
|
|
8ac4caf930 | ||
|
|
ab85f46529 | ||
|
|
5208c7c727 | ||
|
|
12b360f499 | ||
|
|
d1a631123f | ||
|
|
b799650357 | ||
|
|
2ff4a07544 | ||
|
|
1df6821ec6 | ||
|
|
4a5243886a | ||
|
|
e1e4c9437a | ||
|
|
f950a941e3 | ||
|
|
ac579c1746 | ||
|
|
0a5c5ed4ce | ||
|
|
0a2dac1e56 | ||
|
|
737116e83c | ||
|
|
b20eae697e | ||
|
|
07f3f5eec5 | ||
|
|
47cad59c70 | ||
|
|
b17e2b411a | ||
|
|
4c88fbfd5e | ||
|
|
d4d2e511ed |
@@ -19,7 +19,7 @@ jobs:
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v3
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v3
|
||||
with:
|
||||
images: |
|
||||
chrislusf/seaweedfs
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
chrislusf/seaweedfs
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
chrislusf/seaweedfs
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
chrislusf/seaweedfs
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
- name: Docker meta
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
chrislusf/seaweedfs
|
||||
|
||||
@@ -23,14 +23,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
+15
-18
@@ -19,13 +19,12 @@ jobs:
|
||||
name: Go Vet
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
cd weed; go get -v -t -d ./...
|
||||
@@ -42,13 +41,12 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Build
|
||||
run: cd weed; go build -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v .
|
||||
|
||||
@@ -56,12 +54,11 @@ jobs:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
|
||||
with:
|
||||
go-version: ^1.13
|
||||
id: go
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Test
|
||||
run: cd weed; go test -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v ./...
|
||||
|
||||
@@ -116,6 +116,107 @@ jobs:
|
||||
echo "✓ COSI driver renders correctly"
|
||||
|
||||
echo ""
|
||||
echo "=== Testing long release name: service names match DNS references ==="
|
||||
# Use a release name that, combined with chart name "seaweedfs", exceeds 63 chars.
|
||||
# fullname = "my-very-long-release-name-that-will-cause-truncation-seaweedfs" (65 chars before trunc)
|
||||
LONG_RELEASE="my-very-long-release-name-that-will-cause-truncation"
|
||||
|
||||
# --- Normal mode: master + filer-client services vs helper-produced addresses ---
|
||||
helm template "$LONG_RELEASE" $CHART_DIR \
|
||||
--set s3.enabled=true \
|
||||
--set global.createBuckets[0].name=test > /tmp/longname.yaml
|
||||
|
||||
# Extract Service names from metadata
|
||||
MASTER_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname.yaml \
|
||||
| grep -- '-master$')
|
||||
FILER_CLIENT_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname.yaml \
|
||||
| grep -- '-filer-client$')
|
||||
|
||||
# Extract the hostname from WEED_CLUSTER_SW_MASTER in post-install-bucket-hook
|
||||
MASTER_ADDR=$(grep 'WEED_CLUSTER_SW_MASTER' -A1 /tmp/longname.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/')
|
||||
FILER_ADDR=$(grep 'WEED_CLUSTER_SW_FILER' -A1 /tmp/longname.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/')
|
||||
|
||||
# Extract the hostname from S3 deployment -filer= argument
|
||||
S3_FILER_HOST=$(grep '\-filer=' /tmp/longname.yaml \
|
||||
| head -1 | sed 's/.*-filer=\([^:]*\).*/\1/')
|
||||
|
||||
# The address helpers produce "<svc>.<namespace>:<port>"; extract just the svc name
|
||||
MASTER_ADDR_SVC=$(echo "$MASTER_ADDR" | cut -d. -f1)
|
||||
FILER_ADDR_SVC=$(echo "$FILER_ADDR" | cut -d. -f1)
|
||||
S3_FILER_SVC=$(echo "$S3_FILER_HOST" | cut -d. -f1)
|
||||
|
||||
echo " master Service.name: $MASTER_SVC"
|
||||
echo " cluster.masterAddress svc: $MASTER_ADDR_SVC"
|
||||
echo " filer-client Service.name: $FILER_CLIENT_SVC"
|
||||
echo " cluster.filerAddress svc: $FILER_ADDR_SVC"
|
||||
echo " S3 -filer= svc: $S3_FILER_SVC"
|
||||
|
||||
[ "$MASTER_SVC" = "$MASTER_ADDR_SVC" ] || { echo "FAIL: master service name mismatch"; exit 1; }
|
||||
[ "$FILER_CLIENT_SVC" = "$FILER_ADDR_SVC" ] || { echo "FAIL: filer-client service name mismatch"; exit 1; }
|
||||
[ "$FILER_CLIENT_SVC" = "$S3_FILER_SVC" ] || { echo "FAIL: S3 -filer= does not match filer-client service"; exit 1; }
|
||||
echo "✓ Normal mode: service names match DNS references with long release name"
|
||||
|
||||
# --- All-in-one mode: all-in-one service vs both helper addresses ---
|
||||
helm template "$LONG_RELEASE" $CHART_DIR \
|
||||
--set allInOne.enabled=true \
|
||||
--set global.createBuckets[0].name=test > /tmp/longname-aio.yaml
|
||||
|
||||
AIO_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname-aio.yaml \
|
||||
| grep -- '-all-in-one$')
|
||||
AIO_MASTER_ADDR_SVC=$(grep 'WEED_CLUSTER_SW_MASTER' -A1 /tmp/longname-aio.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/' | cut -d. -f1)
|
||||
AIO_FILER_ADDR_SVC=$(grep 'WEED_CLUSTER_SW_FILER' -A1 /tmp/longname-aio.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/' | cut -d. -f1)
|
||||
|
||||
echo " all-in-one Service.name: $AIO_SVC"
|
||||
echo " cluster.masterAddress svc: $AIO_MASTER_ADDR_SVC"
|
||||
echo " cluster.filerAddress svc: $AIO_FILER_ADDR_SVC"
|
||||
|
||||
[ "$AIO_SVC" = "$AIO_MASTER_ADDR_SVC" ] || { echo "FAIL: all-in-one master address mismatch"; exit 1; }
|
||||
[ "$AIO_SVC" = "$AIO_FILER_ADDR_SVC" ] || { echo "FAIL: all-in-one filer address mismatch"; exit 1; }
|
||||
echo "✓ All-in-one mode: service names match DNS references with long release name"
|
||||
|
||||
echo ""
|
||||
echo "=== Testing security+S3: no blank lines in shell command blocks ==="
|
||||
# Render the three manifests that include seaweedfs.s3.tlsArgs:
|
||||
# filer-statefulset, s3-deployment, all-in-one-deployment
|
||||
helm template test $CHART_DIR \
|
||||
--set global.enableSecurity=true \
|
||||
--set filer.s3.enabled=true \
|
||||
--set s3.enabled=true > /tmp/security-s3.yaml
|
||||
helm template test $CHART_DIR \
|
||||
--set global.enableSecurity=true \
|
||||
--set allInOne.enabled=true \
|
||||
--set allInOne.s3.enabled=true > /tmp/security-aio.yaml
|
||||
|
||||
pip install pyyaml -q
|
||||
python3 - /tmp/security-s3.yaml /tmp/security-aio.yaml <<'PYEOF'
|
||||
import yaml, sys
|
||||
errors = []
|
||||
for path in sys.argv[1:]:
|
||||
with open(path) as f:
|
||||
docs = list(yaml.safe_load_all(f))
|
||||
for doc in docs:
|
||||
if not doc or doc.get("kind") not in ("Deployment", "StatefulSet"):
|
||||
continue
|
||||
name = doc["metadata"]["name"]
|
||||
for c in doc["spec"]["template"]["spec"].get("containers", []):
|
||||
cmd = c.get("command", [])
|
||||
if len(cmd) >= 3 and cmd[0] == "/bin/sh" and cmd[1] == "-ec":
|
||||
script = cmd[2]
|
||||
for i, line in enumerate(script.splitlines(), 1):
|
||||
if line.strip() == "":
|
||||
errors.append(f"{path}: {name}/{c['name']} has blank line at script line {i}")
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f"FAIL: {e}", file=sys.stderr)
|
||||
print("Rendered with: global.enableSecurity=true, filer.s3.enabled=true, s3.enabled=true, allInOne.enabled=true", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("✓ No blank lines in security+S3 command blocks")
|
||||
PYEOF
|
||||
|
||||
echo "✅ All template rendering tests passed!"
|
||||
|
||||
- name: Create kind cluster
|
||||
|
||||
@@ -30,7 +30,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
TEST_TIMEOUT: '10m'
|
||||
|
||||
jobs:
|
||||
@@ -43,10 +42,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Build SeaweedFS
|
||||
run: |
|
||||
|
||||
@@ -24,7 +24,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
TEST_TIMEOUT: '15m'
|
||||
|
||||
jobs:
|
||||
@@ -37,10 +36,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -28,7 +28,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
TEST_TIMEOUT: '30m'
|
||||
|
||||
jobs:
|
||||
@@ -46,10 +45,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Build SeaweedFS binary
|
||||
run: |
|
||||
|
||||
@@ -96,7 +96,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
@@ -124,9 +124,9 @@ require (
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
|
||||
github.com/arangodb/go-driver v1.6.9
|
||||
github.com/armon/go-metrics v0.4.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
|
||||
github.com/cognusion/imaging v1.0.2
|
||||
github.com/fluent/fluent-logger-golang v1.10.1
|
||||
@@ -151,7 +151,7 @@ require (
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/seaweedfs/go-fuse/v2 v2.9.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.1
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.2
|
||||
github.com/testcontainers/testcontainers-go v0.39.0
|
||||
github.com/tikv/client-go/v2 v2.0.7
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
@@ -183,7 +183,7 @@ require (
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.4.1 // indirect
|
||||
github.com/apache/thrift v0.22.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bazelbuild/rules_go v0.46.0 // indirect
|
||||
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f // indirect
|
||||
@@ -326,21 +326,21 @@ require (
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
|
||||
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/boltdb/bolt v1.3.1 // indirect
|
||||
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||
@@ -443,7 +443,7 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25
|
||||
github.com/pierrec/lz4/v4 v4.1.26
|
||||
github.com/pingcap/errors v0.11.5-0.20211224045212-9687c2b0f87c // indirect
|
||||
github.com/pingcap/failpoint v0.0.0-20220801062533-2eaa32854a6c // indirect
|
||||
github.com/pingcap/kvproto v0.0.0-20230403051650-e166ae588106 // indirect
|
||||
|
||||
@@ -716,48 +716,48 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+
|
||||
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 h1:Zy6Tme1AA13kX8x3CnkHx5cqdGWGaj/anwOiWGnA0Xo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12/go.mod h1:ql4uXYKoTM9WUAUSmthY4AtPVrlTBZOvnBJTiCUdPxI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7 h1:fovS7qGMT+BBSuifkySdVaMWxXTyaYT6qaBx/1y6Ij4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7/go.mod h1:gFahrattA8ulEtiS4XL/fQiQ77l+Urc52Y96/r1e6ks=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.17 h1:ZNMxVFPayuHe14u/vn+BwLi3wxQvxcNTw8WdPv2gqBc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.17/go.mod h1:ZxqweFQ2w6NNznWMUvWV9AvkAfM6J8F/MC250Mb4n1I=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
@@ -1698,8 +1698,8 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
|
||||
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pierrre/compare v1.0.2 h1:k4IUsHgh+dbcAOIWCfxVa/7G6STjADH2qmhomv+1quc=
|
||||
github.com/pierrre/compare v1.0.2/go.mod h1:8UvyRHH+9HS8Pczdd2z5x/wvv67krDwVxoOndaIIDVU=
|
||||
github.com/pierrre/geohash v1.0.0 h1:f/zfjdV4rVofTCz1FhP07T+EMQAvcMM2ioGZVt+zqjI=
|
||||
@@ -1940,8 +1940,8 @@ github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyi
|
||||
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
|
||||
github.com/tarantool/go-iproto v1.1.0 h1:HULVOIHsiehI+FnHfM7wMDntuzUddO09DKqu2WnFQ5A=
|
||||
github.com/tarantool/go-iproto v1.1.0/go.mod h1:LNCtdyZxojUed8SbOiYHoc3v9NvaZTB7p96hUySMlIo=
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.1 h1:Bk9mh+gMPVmHTSefHvVBpEkf6P2UZA/8xa5kqgyQtyo=
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.1/go.mod h1:MTbhdjFc3Jl63Lgi/UJr5D+QbT+QegqOzsNJGmaw7VM=
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.2 h1:rkzYtFhLJLA9RDIhjzN93MJBN5PBxHW4+soq+RB90gE=
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.2/go.mod h1:MTbhdjFc3Jl63Lgi/UJr5D+QbT+QegqOzsNJGmaw7VM=
|
||||
github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts=
|
||||
github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8=
|
||||
github.com/testcontainers/testcontainers-go/modules/compose v0.39.0 h1:N9Kn9UOIq24o3Y01SFDYF5y3hpq4dNBzDS4pynHb/OQ=
|
||||
@@ -2380,8 +2380,8 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri
|
||||
golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
|
||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
description: SeaweedFS
|
||||
name: seaweedfs
|
||||
appVersion: "4.16"
|
||||
appVersion: "4.17"
|
||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||
version: 4.16.0
|
||||
version: 4.17.0
|
||||
|
||||
@@ -165,8 +165,9 @@ admin:
|
||||
enabled: true
|
||||
port: 23646
|
||||
grpcPort: 33646 # For worker connections
|
||||
adminUser: "admin"
|
||||
adminPassword: "your-secure-password" # Leave empty to disable auth
|
||||
secret:
|
||||
adminUser: "admin"
|
||||
adminPassword: "your-secure-password" # Leave empty to disable auth
|
||||
|
||||
# Optional: persist admin data
|
||||
data:
|
||||
@@ -191,6 +192,8 @@ If `adminPassword` is set, the admin interface requires authentication:
|
||||
|
||||
If `adminPassword` is empty or not set, the admin interface runs without authentication (not recommended for production).
|
||||
|
||||
As an alternative, a kubernetes Secret can be used (`admin.secret.existingSecret`).
|
||||
|
||||
### Admin Data Persistence
|
||||
|
||||
The admin component can store configuration and maintenance data. You can configure storage in several ways:
|
||||
|
||||
@@ -1539,7 +1539,7 @@
|
||||
"pluginVersion": "10.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$NAMESPACE\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$NAMESPACE\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1584,7 +1584,7 @@
|
||||
"pluginVersion": "10.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$NAMESPACE\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$NAMESPACE\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1679,7 +1679,7 @@
|
||||
"pluginVersion": "10.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$NAMESPACE\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$NAMESPACE\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
|
||||
@@ -93,12 +93,12 @@ spec:
|
||||
{{- $adminSecretName := .Values.admin.secret.existingSecret | default (printf "%s-admin-secret" (include "seaweedfs.fullname" .)) }}
|
||||
env:
|
||||
{{- if $adminAuthEnabled }}
|
||||
- name: SEAWEEDFS_ADMIN_USER
|
||||
- name: WEED_ADMIN_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $adminSecretName }}
|
||||
key: {{ if .Values.admin.secret.existingSecret }}{{ .Values.admin.secret.userKey }}{{ else }}adminUser{{ end }}
|
||||
- name: SEAWEEDFS_ADMIN_PASSWORD
|
||||
- name: WEED_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $adminSecretName }}
|
||||
@@ -153,10 +153,6 @@ spec:
|
||||
{{- else if .Values.admin.dataDir }}
|
||||
-dataDir={{ .Values.admin.dataDir }} \
|
||||
{{- end }}
|
||||
{{- if $adminAuthEnabled }}
|
||||
-adminUser="${SEAWEEDFS_ADMIN_USER}" \
|
||||
-adminPassword="${SEAWEEDFS_ADMIN_PASSWORD}" \
|
||||
{{- end }}
|
||||
{{- if .Values.admin.masters }}
|
||||
-masters={{ .Values.admin.masters }}{{- if .Values.admin.extraArgs }} \{{ end }}
|
||||
{{- else if .Values.global.masterServer }}
|
||||
|
||||
@@ -243,7 +243,7 @@ spec:
|
||||
{{- if $httpsPort }}
|
||||
-s3.port.https={{ $httpsPort }} \
|
||||
{{- end }}
|
||||
{{ include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth }}
|
||||
-s3.config=/etc/sw/s3/seaweedfs_s3_config \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-all-in-one" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "all-in-one") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-filer-client" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "filer-client") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -4,7 +4,7 @@ kind: Service
|
||||
metadata:
|
||||
annotations:
|
||||
service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
|
||||
name: {{ printf "%s-filer" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "filer") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -200,7 +200,7 @@ spec:
|
||||
{{- if .Values.filer.s3.httpsPort }}
|
||||
-s3.port.https={{ .Values.filer.s3.httpsPort }} \
|
||||
{{- end }}
|
||||
{{ include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.s3.enableAuth }}
|
||||
-s3.config=/etc/sw/seaweedfs_s3_config \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-master" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "master") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -127,7 +127,7 @@ spec:
|
||||
{{- if .Values.s3.httpsPort }}
|
||||
-port.https={{ .Values.s3.httpsPort }} \
|
||||
{{- end }}
|
||||
{{ include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "") | nindent 14 }}
|
||||
{{- include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "") | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- if .Values.s3.domainName }}
|
||||
-domainName={{ .Values.s3.domainName }} \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-s3" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "s3") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -49,7 +49,7 @@ spec:
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{ tpl .Values.sftp.topologySpreadConstraint . | nindent 8 | trim }}
|
||||
{{ tpl .Values.sftp.topologySpreadConstraints . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.tolerations }}
|
||||
tolerations:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-sftp" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "sftp") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -287,11 +287,8 @@ Compute the master service address to be used in cluster env vars.
|
||||
If allInOne is enabled, point to the all-in-one service; otherwise, point to the master service.
|
||||
*/}}
|
||||
{{- define "seaweedfs.cluster.masterAddress" -}}
|
||||
{{- $serviceNameSuffix := "-master" -}}
|
||||
{{- if .Values.allInOne.enabled -}}
|
||||
{{- $serviceNameSuffix = "-all-in-one" -}}
|
||||
{{- end -}}
|
||||
{{- printf "%s.%s:%d" (printf "%s%s" (include "seaweedfs.fullname" .) $serviceNameSuffix | trunc 63 | trimSuffix "-") .Release.Namespace (int .Values.master.port) -}}
|
||||
{{- $component := ternary "all-in-one" "master" .Values.allInOne.enabled -}}
|
||||
{{- printf "%s.%s:%d" (include "seaweedfs.componentName" (list . $component)) .Release.Namespace (int .Values.master.port) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
@@ -299,11 +296,8 @@ Compute the filer service address to be used in cluster env vars.
|
||||
If allInOne is enabled, point to the all-in-one service; otherwise, point to the filer-client service.
|
||||
*/}}
|
||||
{{- define "seaweedfs.cluster.filerAddress" -}}
|
||||
{{- $serviceNameSuffix := "-filer-client" -}}
|
||||
{{- if .Values.allInOne.enabled -}}
|
||||
{{- $serviceNameSuffix = "-all-in-one" -}}
|
||||
{{- end -}}
|
||||
{{- printf "%s.%s:%d" (printf "%s%s" (include "seaweedfs.fullname" .) $serviceNameSuffix | trunc 63 | trimSuffix "-") .Release.Namespace (int .Values.filer.port) -}}
|
||||
{{- $component := ternary "all-in-one" "filer-client" .Values.allInOne.enabled -}}
|
||||
{{- printf "%s.%s:%d" (include "seaweedfs.componentName" (list . $component)) .Release.Namespace (int .Values.filer.port) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-%s" (include "seaweedfs.fullname" $) $volumeName | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list $ $volumeName) }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" $ }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-worker" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "worker") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -213,6 +213,7 @@ message UpdateEntryRequest {
|
||||
Entry entry = 2;
|
||||
bool is_from_other_cluster = 3;
|
||||
repeated int32 signatures = 4;
|
||||
map<string, bytes> expected_extended = 5;
|
||||
}
|
||||
message UpdateEntryResponse {
|
||||
SubscribeMetadataResponse metadata_event = 1;
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -547,7 +547,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -649,7 +649,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -731,7 +731,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -1182,7 +1182,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1275,7 +1275,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1368,7 +1368,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -3309,4 +3309,4 @@
|
||||
"title": "SeaweedFS",
|
||||
"uid": "nh02dOVnz",
|
||||
"version": 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +754,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -835,7 +835,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -914,7 +914,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -403,7 +403,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -496,7 +496,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
v1credentials "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v1signer "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
v1s3 "github.com/aws/aws-sdk-go/service/s3"
|
||||
v2aws "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
v2s3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
v1credentials "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v1signer "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
v1s3 "github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -187,8 +187,8 @@ func testGetObjectAttributesMultipart(t *testing.T, cluster *TestCluster) {
|
||||
|
||||
// Test pagination: MaxParts=1
|
||||
resp2, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
MaxParts: v2aws.Int32(1),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesObjectParts,
|
||||
|
||||
@@ -0,0 +1,987 @@
|
||||
// Package maintenance contains integration tests for the iceberg table
|
||||
// maintenance plugin worker. Tests start a real weed mini cluster, create
|
||||
// tables via the S3 Tables API, populate Iceberg metadata via the filer
|
||||
// gRPC API, and then exercise the iceberg.Handler operations against the
|
||||
// live filer.
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/command"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
icebergHandler "github.com/seaweedfs/seaweedfs/weed/plugin/worker/iceberg"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cluster lifecycle (mirrors test/s3tables/table-buckets pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type testCluster struct {
|
||||
dataDir string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
filerGrpcPort int
|
||||
s3Port int
|
||||
s3Endpoint string
|
||||
isRunning bool
|
||||
}
|
||||
|
||||
var shared *testCluster
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweed-iceberg-maint-*")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: failed to create temp dir: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cluster, err := startCluster(testDir, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: failed to start cluster: %v\n", err)
|
||||
os.RemoveAll(testDir)
|
||||
os.Exit(0)
|
||||
}
|
||||
shared = cluster
|
||||
|
||||
code := m.Run()
|
||||
shared.stop()
|
||||
os.RemoveAll(testDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func startCluster(testDir string, extraArgs []string) (*testCluster, error) {
|
||||
ports, err := findPorts(10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
masterPort, masterGrpc := ports[0], ports[1]
|
||||
volumePort, volumeGrpc := ports[2], ports[3]
|
||||
filerPort, filerGrpc := ports[4], ports[5]
|
||||
s3Port, s3Grpc := ports[6], ports[7]
|
||||
adminPort, adminGrpc := ports[8], ports[9]
|
||||
|
||||
_ = os.Remove(filepath.Join(testDir, "mini.options"))
|
||||
|
||||
// Empty security.toml disables JWT auth.
|
||||
if err := os.WriteFile(filepath.Join(testDir, "security.toml"), []byte("# test\n"), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c := &testCluster{
|
||||
dataDir: testDir,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
filerGrpcPort: filerGrpc,
|
||||
s3Port: s3Port,
|
||||
s3Endpoint: fmt.Sprintf("http://127.0.0.1:%d", s3Port),
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
oldDir, _ := os.Getwd()
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Chdir(oldDir); os.Args = oldArgs }()
|
||||
os.Chdir(testDir)
|
||||
|
||||
args := []string{
|
||||
"-dir=" + testDir,
|
||||
"-master.dir=" + testDir,
|
||||
"-master.port=" + strconv.Itoa(masterPort),
|
||||
"-master.port.grpc=" + strconv.Itoa(masterGrpc),
|
||||
"-volume.port=" + strconv.Itoa(volumePort),
|
||||
"-volume.port.grpc=" + strconv.Itoa(volumeGrpc),
|
||||
"-volume.port.public=" + strconv.Itoa(volumePort),
|
||||
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
|
||||
"-filer.port=" + strconv.Itoa(filerPort),
|
||||
"-filer.port.grpc=" + strconv.Itoa(filerGrpc),
|
||||
"-s3.port=" + strconv.Itoa(s3Port),
|
||||
"-s3.port.grpc=" + strconv.Itoa(s3Grpc),
|
||||
"-admin.port=" + strconv.Itoa(adminPort),
|
||||
"-admin.port.grpc=" + strconv.Itoa(adminGrpc),
|
||||
"-webdav.port=0",
|
||||
"-admin.ui=false",
|
||||
"-master.volumeSizeLimitMB=32",
|
||||
"-ip=127.0.0.1",
|
||||
"-master.peers=none",
|
||||
"-s3.iam.readOnly=false",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
os.Args = append([]string{"weed"}, args...)
|
||||
glog.MaxSize = 1024 * 1024
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" && cmd.Run != nil {
|
||||
cmd.Flag.Parse(os.Args[1:])
|
||||
command.MiniClusterCtx = ctx
|
||||
cmd.Run(cmd, cmd.Flag.Args())
|
||||
command.MiniClusterCtx = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := waitReady(c.s3Endpoint, 30*time.Second); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
c.isRunning = true
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *testCluster) stop() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
if c.isRunning {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { c.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testCluster) filerConn(t *testing.T) (*grpc.ClientConn, filer_pb.SeaweedFilerClient) {
|
||||
t.Helper()
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort)
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn, filer_pb.NewSeaweedFilerClient(conn)
|
||||
}
|
||||
|
||||
func findPorts(n int) ([]int, error) {
|
||||
ls := make([]*net.TCPListener, n)
|
||||
ps := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
for j := 0; j < i; j++ {
|
||||
ls[j].Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ls[i] = l.(*net.TCPListener)
|
||||
ps[i] = ls[i].Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
for _, l := range ls {
|
||||
l.Close()
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func waitReady(endpoint string, timeout time.Duration) error {
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(endpoint)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for %s", endpoint)
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for populating Iceberg table state via filer gRPC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestSchema() *iceberg.Schema {
|
||||
return iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
|
||||
iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
|
||||
iceberg.NestedField{ID: 3, Type: iceberg.PrimitiveTypes.String, Name: "payload", Required: false},
|
||||
)
|
||||
}
|
||||
|
||||
type compactRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
Payload string `parquet:"payload"`
|
||||
}
|
||||
|
||||
func buildParquetData(t *testing.T, prefix string, rowCount int) []byte {
|
||||
t.Helper()
|
||||
|
||||
rows := make([]compactRow, rowCount)
|
||||
for i := range rows {
|
||||
rows[i] = compactRow{
|
||||
ID: int64(i + 1),
|
||||
Name: fmt.Sprintf("%s-%06d", prefix, i),
|
||||
Payload: fmt.Sprintf("%s-%06d-%s", prefix, i, strings.Repeat(fmt.Sprintf("%04d", i%10000), 128)),
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewGenericWriter[compactRow](&buf)
|
||||
_, err := writer.Write(rows)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func buildMetadata(t *testing.T, snapshots []table.Snapshot) table.Metadata {
|
||||
t.Helper()
|
||||
schema := newTestSchema()
|
||||
meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, "s3://test/table", nil)
|
||||
require.NoError(t, err)
|
||||
if len(snapshots) == 0 {
|
||||
return meta
|
||||
}
|
||||
|
||||
// Iceberg validates that snapshot timestamps >= metadata lastUpdatedMs.
|
||||
// Offset all timestamps so they're safely after the metadata was created.
|
||||
baseMs := time.Now().UnixMilli() + 100
|
||||
for i := range snapshots {
|
||||
snapshots[i].TimestampMs = baseMs + int64(i)
|
||||
}
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(meta, "s3://test/table")
|
||||
require.NoError(t, err)
|
||||
var lastID int64
|
||||
for i := range snapshots {
|
||||
s := snapshots[i]
|
||||
require.NoError(t, builder.AddSnapshot(&s))
|
||||
lastID = s.SnapshotID
|
||||
}
|
||||
require.NoError(t, builder.SetSnapshotRef(table.MainBranch, lastID, table.BranchRef))
|
||||
result, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
// s3put performs an S3-signed PUT request. When key is empty it creates a
|
||||
// bucket; otherwise it uploads an object.
|
||||
func s3put(t *testing.T, s3Endpoint, bucket, key string, body []byte) {
|
||||
t.Helper()
|
||||
urlPath := "/" + bucket
|
||||
if key != "" {
|
||||
urlPath += "/" + key
|
||||
}
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, s3Endpoint+urlPath, reader)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
hash := sha256.Sum256(body)
|
||||
err = v4.NewSigner().SignHTTP(context.Background(),
|
||||
aws.Credentials{AccessKeyID: "admin", SecretAccessKey: "admin"},
|
||||
req, hex.EncodeToString(hash[:]), "s3", "us-east-1", time.Now())
|
||||
require.NoError(t, err)
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
require.FailNowf(t, "s3put failed", "s3put(%s/%s) → %d: %s", bucket, key, resp.StatusCode, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func newS3Client(t *testing.T, endpoint string) *s3.Client {
|
||||
t.Helper()
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
||||
config.WithRegion("us-east-1"),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("admin", "admin", "")),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
|
||||
func s3putObject(t *testing.T, client *s3.Client, bucket, key string, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type testFilerClient struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (c testFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c.client)
|
||||
}
|
||||
|
||||
func (c testFilerClient) AdjustedUrl(location *filer_pb.Location) string {
|
||||
if location == nil {
|
||||
return ""
|
||||
}
|
||||
if location.PublicUrl != "" {
|
||||
return location.PublicUrl
|
||||
}
|
||||
return location.Url
|
||||
}
|
||||
|
||||
func (c testFilerClient) GetDataCenter() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, client filer_pb.SeaweedFilerClient, dir, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
entry := lookupEntry(t, client, dir, name)
|
||||
require.NotNil(t, entry, "readFile(%s, %s): entry missing", dir, name)
|
||||
if len(entry.Content) > 0 || len(entry.Chunks) == 0 {
|
||||
return entry.Content
|
||||
}
|
||||
|
||||
reader := filer.NewFileReader(testFilerClient{client: client}, entry)
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
content, err := io.ReadAll(reader)
|
||||
require.NoError(t, err, "readFile(%s, %s): read chunked content", dir, name)
|
||||
return content
|
||||
}
|
||||
|
||||
// createBucketViaS3 creates a regular S3 bucket via the S3 PUT Bucket API.
|
||||
// populateTableViaFiler creates the Iceberg directory structure, metadata xattr,
|
||||
// manifests, and data files for a table directly in the filer.
|
||||
// The table bucket, namespace, and table must already exist (created via S3
|
||||
// Tables API).
|
||||
func populateTableViaFiler(
|
||||
t *testing.T,
|
||||
client filer_pb.SeaweedFilerClient,
|
||||
s3Endpoint, bucketName, namespace, tableName string,
|
||||
snapshots []table.Snapshot,
|
||||
) table.Metadata {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
meta := buildMetadata(t, snapshots)
|
||||
fullJSON, err := json.Marshal(meta)
|
||||
require.NoError(t, err)
|
||||
|
||||
tablePath := path.Join(namespace, tableName)
|
||||
bucketsPath := s3tables.TablesPath
|
||||
tableFilerPath := path.Join(bucketsPath, bucketName, tablePath)
|
||||
metaDir := path.Join(tableFilerPath, "metadata")
|
||||
dataDir := path.Join(tableFilerPath, "data")
|
||||
|
||||
// Build the table metadata xattr
|
||||
internalMeta := map[string]interface{}{
|
||||
"metadataVersion": 1,
|
||||
"metadataLocation": "metadata/v1.metadata.json",
|
||||
"metadata": map[string]interface{}{
|
||||
"fullMetadata": json.RawMessage(fullJSON),
|
||||
},
|
||||
}
|
||||
xattr, err := json.Marshal(internalMeta)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create the S3 bucket via PUT, then create the directory tree via S3 PUT
|
||||
// object with zero-byte content (the filer creates intermediate dirs).
|
||||
s3put(t, s3Endpoint, bucketName, "", nil) // create bucket
|
||||
s3put(t, s3Endpoint, bucketName, namespace+"/.dir", []byte{}) // create ns dir
|
||||
s3put(t, s3Endpoint, bucketName, tablePath+"/.dir", []byte{}) // create table dir
|
||||
s3put(t, s3Endpoint, bucketName, tablePath+"/metadata/.dir", []byte{}) // create metadata dir
|
||||
s3put(t, s3Endpoint, bucketName, tablePath+"/data/.dir", []byte{}) // create data dir
|
||||
|
||||
// Now set the table bucket and table xattrs via filer gRPC.
|
||||
// Mark bucket as table bucket.
|
||||
bucketEntry := lookupEntry(t, client, bucketsPath, bucketName)
|
||||
require.NotNil(t, bucketEntry, "bucket should exist after S3 PUT")
|
||||
if bucketEntry.Extended == nil {
|
||||
bucketEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
bucketEntry.Extended[s3tables.ExtendedKeyTableBucket] = []byte("true")
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: bucketsPath, Entry: bucketEntry,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set table metadata xattr.
|
||||
nsDir := path.Join(bucketsPath, bucketName, namespace)
|
||||
tableEntry := lookupEntry(t, client, nsDir, tableName)
|
||||
require.NotNil(t, tableEntry, "table dir should exist after S3 PUT")
|
||||
if tableEntry.Extended == nil {
|
||||
tableEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
tableEntry.Extended[s3tables.ExtendedKeyMetadata] = xattr
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: nsDir, Entry: tableEntry,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write manifest + manifest list + data file for each snapshot
|
||||
schema := meta.CurrentSchema()
|
||||
spec := meta.PartitionSpec()
|
||||
version := meta.Version()
|
||||
|
||||
for _, snap := range snapshots {
|
||||
if snap.ManifestList == "" {
|
||||
continue
|
||||
}
|
||||
snapID := snap.SnapshotID
|
||||
dataFileName := fmt.Sprintf("snap-%d-data.parquet", snapID)
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec, iceberg.EntryContentData,
|
||||
path.Join("data", dataFileName), iceberg.ParquetFile,
|
||||
map[int]any{}, nil, nil, 10, 4096,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build(),
|
||||
)
|
||||
|
||||
// Manifest
|
||||
manifestName := fmt.Sprintf("manifest-%d.avro", snapID)
|
||||
var manifestBuf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(
|
||||
path.Join("metadata", manifestName), &manifestBuf,
|
||||
version, spec, schema, snapID, []iceberg.ManifestEntry{entry},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
writeFile(t, ctx, client, metaDir, manifestName, manifestBuf.Bytes())
|
||||
|
||||
// Manifest list
|
||||
mlName := path.Base(snap.ManifestList)
|
||||
var mlBuf bytes.Buffer
|
||||
parent := snap.ParentSnapshotID
|
||||
seqNum := snap.SequenceNumber
|
||||
require.NoError(t, iceberg.WriteManifestList(
|
||||
version, &mlBuf, snapID, parent, &seqNum, 0,
|
||||
[]iceberg.ManifestFile{mf},
|
||||
))
|
||||
writeFile(t, ctx, client, metaDir, mlName, mlBuf.Bytes())
|
||||
|
||||
// Data file (dummy content)
|
||||
writeFile(t, ctx, client, dataDir, dataFileName, []byte("fake-parquet"))
|
||||
}
|
||||
|
||||
// Wait for snapshot timestamps (set slightly in the future by
|
||||
// buildMetadata) to move into the past so expiration checks work.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
|
||||
t.Helper()
|
||||
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(), Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0644), FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "writeFile(%s, %s): rpc error", dir, name)
|
||||
require.Empty(t, resp.Error, "writeFile(%s, %s): resp error", dir, name)
|
||||
}
|
||||
|
||||
func upsertFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
|
||||
t.Helper()
|
||||
|
||||
entry := lookupEntry(t, client, dir, name)
|
||||
if entry == nil {
|
||||
writeFile(t, ctx, client, dir, name, content)
|
||||
return
|
||||
}
|
||||
if entry.Attributes == nil {
|
||||
entry.Attributes = &filer_pb.FuseAttributes{}
|
||||
}
|
||||
entry.Content = content
|
||||
entry.Chunks = nil
|
||||
entry.Attributes.Mtime = time.Now().Unix()
|
||||
entry.Attributes.FileMode = uint32(0644)
|
||||
entry.Attributes.FileSize = uint64(len(content))
|
||||
resp, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: entry,
|
||||
})
|
||||
require.NoError(t, err, "upsertFile(%s, %s): rpc error", dir, name)
|
||||
require.NotNil(t, resp, "upsertFile(%s, %s): nil response", dir, name)
|
||||
}
|
||||
|
||||
func lookupEntry(t *testing.T, client filer_pb.SeaweedFilerClient, dir, name string) *filer_pb.Entry {
|
||||
t.Helper()
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir, Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
require.NoError(t, err, "lookupEntry(%s, %s): unexpected error", dir, name)
|
||||
}
|
||||
return resp.Entry
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIcebergMaintenanceIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
t.Run("ExpireSnapshots", testExpireSnapshots)
|
||||
t.Run("CompactDataFiles", testCompactDataFiles)
|
||||
t.Run("RemoveOrphans", testRemoveOrphans)
|
||||
t.Run("RewriteManifests", testRewriteManifests)
|
||||
t.Run("FullMaintenanceCycle", testFullMaintenanceCycle)
|
||||
}
|
||||
|
||||
func testExpireSnapshots(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-expire-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
SnapshotRetentionHours: 0, // instant expiry — everything eligible
|
||||
MaxSnapshotsToKeep: 1, // keep only the current snapshot
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, _, err := handler.ExpireSnapshots(context.Background(), client, bucket, path.Join(ns, tbl), config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "expired")
|
||||
t.Logf("ExpireSnapshots result: %s", result)
|
||||
|
||||
// Verify metadata was updated
|
||||
entry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
|
||||
require.NotNil(t, entry)
|
||||
xattr := entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
require.NotEmpty(t, xattr)
|
||||
|
||||
// Parse updated metadata to verify snapshot count
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
|
||||
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should have been incremented")
|
||||
}
|
||||
|
||||
func testCompactDataFiles(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-compact-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro", SequenceNumber: 1},
|
||||
}
|
||||
meta := populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
ctx := context.Background()
|
||||
tablePath := path.Join(ns, tbl)
|
||||
dataDir := path.Join(s3tables.TablesPath, bucket, tablePath, "data")
|
||||
metaDir := path.Join(s3tables.TablesPath, bucket, tablePath, "metadata")
|
||||
|
||||
type parquetFile struct {
|
||||
name string
|
||||
recordCount int64
|
||||
fileSize int64
|
||||
}
|
||||
|
||||
s3Client := newS3Client(t, shared.s3Endpoint)
|
||||
var files []parquetFile
|
||||
for i := 1; i <= 3; i++ {
|
||||
name := fmt.Sprintf("compact-input-%d.parquet", i)
|
||||
content := buildParquetData(t, fmt.Sprintf("compact-%d", i), 2000)
|
||||
s3putObject(t, s3Client, bucket, path.Join(tablePath, "data", name), content)
|
||||
|
||||
entry := lookupEntry(t, client, dataDir, name)
|
||||
require.NotNil(t, entry, "uploaded parquet file should exist")
|
||||
require.NotEmpty(t, entry.Chunks, "uploaded parquet file should be chunk-backed")
|
||||
|
||||
files = append(files, parquetFile{
|
||||
name: name,
|
||||
recordCount: 2000,
|
||||
fileSize: int64(len(content)),
|
||||
})
|
||||
}
|
||||
|
||||
schema := meta.CurrentSchema()
|
||||
spec := meta.PartitionSpec()
|
||||
version := meta.Version()
|
||||
snapID := snapshots[0].SnapshotID
|
||||
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(files))
|
||||
for _, file := range files {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
path.Join("data", file.name),
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
file.recordCount,
|
||||
file.fileSize,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
entries = append(entries, iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build(),
|
||||
))
|
||||
}
|
||||
|
||||
manifestName := "manifest-compact.avro"
|
||||
var manifestBuf bytes.Buffer
|
||||
manifest, err := iceberg.WriteManifest(
|
||||
path.Join("metadata", manifestName), &manifestBuf,
|
||||
version, spec, schema, snapID, entries,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
writeFile(t, ctx, client, metaDir, manifestName, manifestBuf.Bytes())
|
||||
|
||||
var manifestListBuf bytes.Buffer
|
||||
seqNum := snapshots[0].SequenceNumber
|
||||
require.NoError(t, iceberg.WriteManifestList(
|
||||
version, &manifestListBuf, snapID, snapshots[0].ParentSnapshotID, &seqNum, 0,
|
||||
[]iceberg.ManifestFile{manifest},
|
||||
))
|
||||
upsertFile(t, ctx, client, metaDir, path.Base(snapshots[0].ManifestList), manifestListBuf.Bytes())
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
TargetFileSizeBytes: 16 * 1024 * 1024,
|
||||
MinInputFiles: 2,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, metrics, err := handler.CompactDataFiles(ctx, client, bucket, tablePath, config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "compacted")
|
||||
require.NotNil(t, metrics, "expected non-nil metrics from CompactDataFiles")
|
||||
assert.Contains(t, metrics, icebergHandler.MetricFilesMerged)
|
||||
assert.Contains(t, metrics, icebergHandler.MetricFilesWritten)
|
||||
assert.Contains(t, metrics, icebergHandler.MetricBins)
|
||||
assert.Contains(t, metrics, icebergHandler.MetricDurationMs)
|
||||
t.Logf("CompactDataFiles result: %s, metrics: %v", result, metrics)
|
||||
|
||||
var compacted *filer_pb.Entry
|
||||
listErr := filer_pb.SeaweedList(ctx, client, dataDir, "", func(entry *filer_pb.Entry, isLast bool) error {
|
||||
// Compacted output uses compact-{snapID}-{newSnapID}-{binIdx}.parquet.
|
||||
// The test inputs use compact-input-{n}.parquet and must not satisfy this check.
|
||||
if strings.HasPrefix(entry.Name, "compact-") &&
|
||||
!strings.HasPrefix(entry.Name, "compact-input-") &&
|
||||
strings.HasSuffix(entry.Name, ".parquet") {
|
||||
compacted = entry
|
||||
}
|
||||
return nil
|
||||
}, "", false, 1000)
|
||||
require.NoError(t, listErr)
|
||||
require.NotNil(t, compacted, "compaction should create a merged parquet file")
|
||||
|
||||
tableEntry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
|
||||
require.NotNil(t, tableEntry)
|
||||
xattr := tableEntry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
require.NotEmpty(t, xattr)
|
||||
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
MetadataLocation string `json:"metadataLocation,omitempty"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
|
||||
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should advance after compaction")
|
||||
|
||||
metadataName := path.Base(internalMeta.MetadataLocation)
|
||||
require.NotEmpty(t, metadataName, "compaction should point table metadata at a committed metadata file")
|
||||
metadataBytes := readFile(t, client, metaDir, metadataName)
|
||||
committedMeta, err := table.ParseMetadataBytes(metadataBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
currentSnap := committedMeta.CurrentSnapshot()
|
||||
require.NotNil(t, currentSnap, "committed metadata should include a current snapshot")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, currentSnap.SequenceNumber, "compaction should advance the snapshot sequence number")
|
||||
|
||||
manifestListName := path.Base(currentSnap.ManifestList)
|
||||
require.NotEmpty(t, manifestListName, "committed snapshot should reference a manifest list")
|
||||
manifestListBytes := readFile(t, client, metaDir, manifestListName)
|
||||
manifestFiles, err := iceberg.ReadManifestList(bytes.NewReader(manifestListBytes))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, manifestFiles, 1, "compaction should commit a single replacement manifest")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, manifestFiles[0].SequenceNum(), "new manifest should use the committed snapshot sequence number")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, manifestFiles[0].MinSequenceNum(), "compaction manifest should inherit the committed sequence number for added data")
|
||||
|
||||
manifestName = path.Base(manifestFiles[0].FilePath())
|
||||
manifestBytes := readFile(t, client, metaDir, manifestName)
|
||||
manifestEntries, err := iceberg.ReadManifest(manifestFiles[0], bytes.NewReader(manifestBytes), false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, manifestEntries, len(files)+1, "compaction should replace the input files with one merged output")
|
||||
|
||||
deletedPaths := make(map[string]struct{}, len(files))
|
||||
addedPaths := make(map[string]struct{})
|
||||
for _, entry := range manifestEntries {
|
||||
switch entry.Status() {
|
||||
case iceberg.EntryStatusADDED:
|
||||
addedPaths[entry.DataFile().FilePath()] = struct{}{}
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, entry.SequenceNum(), "added entries should inherit the new snapshot sequence number")
|
||||
fileSeqNum := entry.FileSequenceNum()
|
||||
require.NotNil(t, fileSeqNum, "added entries should carry a file sequence number")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, *fileSeqNum)
|
||||
case iceberg.EntryStatusDELETED:
|
||||
deletedPaths[entry.DataFile().FilePath()] = struct{}{}
|
||||
assert.Equal(t, snapshots[0].SequenceNumber, entry.SequenceNum(), "deleted entries should preserve the original data sequence number")
|
||||
fileSeqNum := entry.FileSequenceNum()
|
||||
require.NotNil(t, fileSeqNum, "deleted entries should preserve file sequence numbers")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber, *fileSeqNum)
|
||||
default:
|
||||
t.Fatalf("unexpected manifest entry status %v for %s", entry.Status(), entry.DataFile().FilePath())
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, addedPaths, 1, "compaction should add exactly one merged parquet file")
|
||||
assert.Contains(t, addedPaths, path.Join("data", compacted.Name))
|
||||
require.Len(t, deletedPaths, len(files), "compaction should delete every original small input file")
|
||||
for _, file := range files {
|
||||
assert.Contains(t, deletedPaths, path.Join("data", file.name))
|
||||
}
|
||||
}
|
||||
|
||||
func testRemoveOrphans(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-orphan-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
ctx := context.Background()
|
||||
tablePath := path.Join(ns, tbl)
|
||||
tableFilerPath := path.Join(s3tables.TablesPath, bucket, tablePath)
|
||||
dataDir := path.Join(tableFilerPath, "data")
|
||||
metaDir := path.Join(tableFilerPath, "metadata")
|
||||
|
||||
// Create orphan files (old enough to be removed)
|
||||
oldTime := time.Now().Add(-200 * time.Hour).Unix()
|
||||
writeOrphan := func(dir, name string) {
|
||||
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: oldTime, Crtime: oldTime,
|
||||
FileMode: uint32(0644), FileSize: 100,
|
||||
},
|
||||
Content: []byte("orphan"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "writeOrphan(%s, %s): rpc error", dir, name)
|
||||
require.Empty(t, resp.Error, "writeOrphan(%s, %s): resp error", dir, name)
|
||||
}
|
||||
writeOrphan(dataDir, "orphan-data.parquet")
|
||||
writeOrphan(metaDir, "orphan-meta.avro")
|
||||
|
||||
// Create a recent orphan (should NOT be removed)
|
||||
writeFile(t, ctx, client, dataDir, "recent-orphan.parquet", []byte("new"))
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
OrphanOlderThanHours: 72,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, _, err := handler.RemoveOrphans(ctx, client, bucket, tablePath, config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "removed")
|
||||
t.Logf("RemoveOrphans result: %s", result)
|
||||
|
||||
// Verify orphans were removed
|
||||
assert.Nil(t, lookupEntry(t, client, dataDir, "orphan-data.parquet"), "old orphan data file should be deleted")
|
||||
assert.Nil(t, lookupEntry(t, client, metaDir, "orphan-meta.avro"), "old orphan meta file should be deleted")
|
||||
|
||||
// Verify recent orphan was kept
|
||||
assert.NotNil(t, lookupEntry(t, client, dataDir, "recent-orphan.parquet"), "recent orphan should be kept")
|
||||
}
|
||||
|
||||
func testRewriteManifests(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-rewrite-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
// Each snapshot gets its own manifest list with one manifest. The current
|
||||
// snapshot (latest) therefore has only 1 manifest — below the rewrite
|
||||
// threshold. This tests the threshold check against a real filer.
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
MinManifestsToRewrite: 5, // threshold higher than 1 manifest
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
tablePath := path.Join(ns, tbl)
|
||||
result, _, err := handler.RewriteManifests(context.Background(), client, bucket, tablePath, config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "below threshold")
|
||||
t.Logf("RewriteManifests result: %s", result)
|
||||
}
|
||||
|
||||
func testFullMaintenanceCycle(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-full-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
ctx := context.Background()
|
||||
tablePath := path.Join(ns, tbl)
|
||||
|
||||
// Add an orphan
|
||||
dataDir := path.Join(s3tables.TablesPath, bucket, tablePath, "data")
|
||||
oldTime := time.Now().Add(-200 * time.Hour).Unix()
|
||||
orphanResp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dataDir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "orphan.parquet",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: oldTime, Crtime: oldTime,
|
||||
FileMode: uint32(0644), FileSize: 100,
|
||||
},
|
||||
Content: []byte("orphan"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, orphanResp.Error)
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
|
||||
// Step 1: Expire snapshots
|
||||
expireConfig := icebergHandler.Config{
|
||||
SnapshotRetentionHours: 0, // instant expiry
|
||||
MaxSnapshotsToKeep: 1,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
result, _, err := handler.ExpireSnapshots(ctx, client, bucket, tablePath, expireConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "expired")
|
||||
t.Logf("Step 1 (expire): %s", result)
|
||||
|
||||
// Step 2: Remove orphans
|
||||
orphanConfig := icebergHandler.Config{
|
||||
OrphanOlderThanHours: 72,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
result, _, err = handler.RemoveOrphans(ctx, client, bucket, tablePath, orphanConfig)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Step 2 (orphans): %s", result)
|
||||
// The orphan and the unreferenced files from expired snapshots should be gone
|
||||
assert.Contains(t, result, "removed")
|
||||
|
||||
// Step 3: Verify metadata is consistent
|
||||
entry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
|
||||
require.NotNil(t, entry)
|
||||
xattr := entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
require.NotEmpty(t, xattr)
|
||||
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
|
||||
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should have advanced through the cycle")
|
||||
t.Logf("Final metadata version: %d", internalMeta.MetadataVersion)
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
# SeaweedFS Admin Component
|
||||
|
||||
A modern web-based administration interface for SeaweedFS clusters built with Go, Gin, Templ, and Bootstrap.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard**: Real-time cluster status and metrics
|
||||
- **Master Management**: Monitor master nodes and leadership status
|
||||
- **Volume Server Management**: View volume servers, capacity, and health
|
||||
- **Object Store Bucket Management**: Create, delete, and manage Object Store buckets with web interface
|
||||
- **S3 Tables Management**: Manage table buckets, namespaces, tables, tags, and policies via the admin UI
|
||||
- **System Health**: Overall cluster health monitoring
|
||||
- **Responsive Design**: Bootstrap-based UI that works on all devices
|
||||
- **Authentication**: Optional user authentication with sessions
|
||||
- **TLS Support**: HTTPS support for production deployments
|
||||
|
||||
## Building
|
||||
|
||||
### Using the Admin Makefile
|
||||
|
||||
The admin component has its own Makefile for development and building:
|
||||
|
||||
```bash
|
||||
# Navigate to admin directory
|
||||
cd weed/admin
|
||||
|
||||
# View all available targets
|
||||
make help
|
||||
|
||||
# Generate templates and build
|
||||
make build
|
||||
|
||||
# Development mode with template watching
|
||||
make dev
|
||||
|
||||
# Run the admin server
|
||||
make run
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Using the Root Makefile
|
||||
|
||||
The root SeaweedFS Makefile automatically integrates the admin component:
|
||||
|
||||
```bash
|
||||
# From the root directory
|
||||
make install # Builds weed with admin component
|
||||
make full_install # Full build with all tags
|
||||
make test # Runs tests including admin component
|
||||
|
||||
# Admin-specific targets from root
|
||||
make admin-generate # Generate admin templates
|
||||
make admin-build # Build admin component
|
||||
make admin-run # Run admin server
|
||||
make admin-dev # Development mode
|
||||
make admin-clean # Clean admin artifacts
|
||||
```
|
||||
|
||||
### Manual Building
|
||||
|
||||
If you prefer to build manually:
|
||||
|
||||
```bash
|
||||
# Install templ compiler
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
|
||||
# Generate templates
|
||||
templ generate
|
||||
|
||||
# Build the main weed binary
|
||||
cd ../../../
|
||||
go build -o weed ./weed
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Template Development
|
||||
|
||||
The admin interface uses [Templ](https://templ.guide/) for type-safe HTML templates:
|
||||
|
||||
```bash
|
||||
# Watch for template changes and auto-regenerate
|
||||
make watch
|
||||
|
||||
# Or manually generate templates
|
||||
make generate
|
||||
|
||||
# Format templates
|
||||
make fmt
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
weed/admin/
|
||||
├── Makefile # Admin-specific build tasks
|
||||
├── README.md # This file
|
||||
├── admin.go # Main application entry point
|
||||
├── dash/ # Server and handler logic
|
||||
│ ├── admin_server.go # HTTP server setup
|
||||
│ ├── handler_admin.go # Admin dashboard handlers
|
||||
│ ├── handler_auth.go # Authentication handlers
|
||||
│ └── middleware.go # HTTP middleware
|
||||
├── static/ # Static assets
|
||||
│ ├── css/admin.css # Admin-specific styles
|
||||
│ └── js/admin.js # Admin-specific JavaScript
|
||||
└── view/ # Templates
|
||||
├── app/ # Application templates
|
||||
│ ├── admin.templ # Main dashboard template
|
||||
│ ├── s3_buckets.templ # Object Store bucket management template
|
||||
│ ├── s3tables_*.templ # S3 Tables management templates
|
||||
│ └── *_templ.go # Generated Go code
|
||||
└── layout/ # Layout templates
|
||||
├── layout.templ # Base layout template
|
||||
└── layout_templ.go # Generated Go code
|
||||
```
|
||||
|
||||
### Object Store Management
|
||||
|
||||
The admin interface includes Object Store and S3 Tables management capabilities:
|
||||
|
||||
- Create/delete Object Store buckets and adjust quotas or ownership.
|
||||
- Manage S3 Tables buckets, namespaces, and tables.
|
||||
- Update S3 Tables policies and tags via the UI and API endpoints.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Start admin interface on default port (23646)
|
||||
weed admin
|
||||
|
||||
# Start with custom configuration
|
||||
weed admin -port=8080 -masters="master1:9333,master2:9333"
|
||||
|
||||
# Start with authentication
|
||||
weed admin -adminUser=admin -adminPassword=secret123
|
||||
|
||||
# Start with HTTPS
|
||||
weed admin -port=443 -tlsCert=/path/to/cert.pem -tlsKey=/path/to/key.pem
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-port` | 23646 | Admin server port |
|
||||
| `-masters` | localhost:9333 | Comma-separated master servers |
|
||||
| `-adminUser` | admin | Admin username (if auth enabled) |
|
||||
| `-adminPassword` | "" | Admin password (empty = no auth) |
|
||||
| `-tlsCert` | "" | Path to TLS certificate |
|
||||
| `-tlsKey` | "" | Path to TLS private key |
|
||||
|
||||
### Docker Usage
|
||||
|
||||
```bash
|
||||
# Build Docker image with admin component
|
||||
make docker-build
|
||||
|
||||
# Run with Docker
|
||||
docker run -p 23646:23646 seaweedfs/seaweedfs:latest admin -masters=host.docker.internal:9333
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <seaweedfs-repo>
|
||||
cd seaweedfs/weed/admin
|
||||
|
||||
# Install dependencies and build
|
||||
make install-deps
|
||||
make build
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
```
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. **Template Changes**: Edit `.templ` files in `view/`
|
||||
- Templates auto-regenerate in development mode
|
||||
- Use `make generate` to manually regenerate
|
||||
|
||||
2. **Go Code Changes**: Edit `.go` files
|
||||
- Restart the server to see changes
|
||||
- Use `make build` to rebuild
|
||||
|
||||
3. **Static Assets**: Edit files in `static/`
|
||||
- Changes are served immediately
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run admin component tests
|
||||
make test
|
||||
|
||||
# Run from root directory
|
||||
make admin-test
|
||||
|
||||
# Lint code
|
||||
make lint
|
||||
|
||||
# Format code
|
||||
make fmt
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Authentication**: Always set `adminPassword` in production
|
||||
2. **HTTPS**: Use TLS certificates for encrypted connections
|
||||
3. **Firewall**: Restrict admin interface access to authorized networks
|
||||
|
||||
### Example Production Setup
|
||||
|
||||
```bash
|
||||
# Production deployment with security
|
||||
weed admin \
|
||||
-port=443 \
|
||||
-masters="master1:9333,master2:9333,master3:9333" \
|
||||
-adminUser=admin \
|
||||
-adminPassword=your-secure-password \
|
||||
-tlsCert=/etc/ssl/certs/admin.crt \
|
||||
-tlsKey=/etc/ssl/private/admin.key
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
The admin interface provides endpoints for monitoring:
|
||||
|
||||
- `GET /health` - Health check endpoint
|
||||
- `GET /metrics` - Prometheus metrics (if enabled)
|
||||
- `GET /api/status` - JSON status information
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Templates not found**: Run `make generate` to create template files
|
||||
2. **Build errors**: Ensure `templ` is installed with `make install-templ`
|
||||
3. **Static files not loading**: Check that `static/` directory exists and has proper files
|
||||
4. **Connection errors**: Verify master and filer addresses are correct
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
weed -v=2 admin
|
||||
|
||||
# Check generated templates
|
||||
ls -la view/app/*_templ.go view/layout/*_templ.go
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests: `make test`
|
||||
5. Format code: `make fmt`
|
||||
6. Submit a pull request
|
||||
|
||||
## Architecture
|
||||
|
||||
The admin component follows a clean architecture:
|
||||
|
||||
- **Presentation Layer**: Templ templates + Bootstrap CSS
|
||||
- **HTTP Layer**: Gin router with middleware
|
||||
- **Business Logic**: Handler functions in `dash/` package
|
||||
- **Data Layer**: Communicates with SeaweedFS masters and filers
|
||||
|
||||
This separation makes the code maintainable and testable.
|
||||
@@ -111,6 +111,9 @@ type AdminServer struct {
|
||||
// Worker gRPC server
|
||||
workerGrpcServer *WorkerGrpcServer
|
||||
|
||||
// Background goroutine lifecycle
|
||||
bgCancel context.CancelFunc
|
||||
|
||||
// Collection statistics caching
|
||||
collectionStatsCache map[string]collectionStats
|
||||
lastCollectionStatsUpdate time.Time
|
||||
@@ -137,8 +140,8 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
|
||||
)
|
||||
|
||||
// Start master client connection process (like shell and filer do)
|
||||
ctx := context.Background()
|
||||
go masterClient.KeepConnectedToMaster(ctx)
|
||||
bgCtx, bgCancel := context.WithCancel(context.Background())
|
||||
go masterClient.KeepConnectedToMaster(bgCtx)
|
||||
|
||||
lockManager := NewAdminLockManager(masterClient, adminLockClientName)
|
||||
presenceLock := newAdminPresenceLock(masterClient)
|
||||
@@ -159,6 +162,7 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
|
||||
icebergPort: icebergPort,
|
||||
pluginLock: lockManager,
|
||||
adminPresenceLock: presenceLock,
|
||||
bgCancel: bgCancel,
|
||||
}
|
||||
|
||||
// Initialize topic retention purger
|
||||
@@ -256,11 +260,93 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
|
||||
} else {
|
||||
server.plugin = plugin
|
||||
glog.V(0).Infof("Plugin enabled")
|
||||
go server.monitorVacuumWorker(bgCtx)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// vacuumToggler abstracts the master's vacuum enable/disable for testing.
|
||||
type vacuumToggler interface {
|
||||
disableVacuum() error
|
||||
enableVacuum() error
|
||||
}
|
||||
|
||||
// masterVacuumToggler implements vacuumToggler via gRPC calls to the master.
|
||||
type masterVacuumToggler struct {
|
||||
server *AdminServer
|
||||
}
|
||||
|
||||
func (m *masterVacuumToggler) disableVacuum() error {
|
||||
return m.server.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := client.DisableVacuum(ctx, &master_pb.DisableVacuumRequest{ByPlugin: true})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (m *masterVacuumToggler) enableVacuum() error {
|
||||
return m.server.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := client.EnableVacuum(ctx, &master_pb.EnableVacuumRequest{ByPlugin: true})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// syncVacuumState performs a single sync step: checks if a vacuum-capable worker
|
||||
// is present and calls disable/enable accordingly. Returns the updated state
|
||||
// and whether the call failed (for log dedup on retries).
|
||||
func syncVacuumState(hasWorker bool, previouslyActive bool, toggler vacuumToggler, retrying bool) (active bool, failed bool) {
|
||||
if hasWorker == previouslyActive {
|
||||
return previouslyActive, false
|
||||
}
|
||||
if hasWorker {
|
||||
if !retrying {
|
||||
glog.V(0).Infof("Vacuum plugin worker connected, disabling master automatic vacuum")
|
||||
}
|
||||
if err := toggler.disableVacuum(); err != nil {
|
||||
glog.Warningf("Failed to disable vacuum on master: %v", err)
|
||||
return false, true // retry next tick
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
if !retrying {
|
||||
glog.V(0).Infof("Vacuum plugin worker disconnected, re-enabling master automatic vacuum")
|
||||
}
|
||||
if err := toggler.enableVacuum(); err != nil {
|
||||
glog.Warningf("Failed to enable vacuum on master: %v", err)
|
||||
return true, true // retry next tick
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// monitorVacuumWorker polls the plugin registry for vacuum-capable workers and
|
||||
// disables/enables the master's automatic scheduled vacuum accordingly.
|
||||
func (s *AdminServer) monitorVacuumWorker(ctx context.Context) {
|
||||
const pollInterval = 30 * time.Second
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
toggler := &masterVacuumToggler{server: s}
|
||||
vacuumWorkerActive := false
|
||||
retrying := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.plugin == nil {
|
||||
continue
|
||||
}
|
||||
hasWorker := s.plugin.HasCapableWorker("vacuum")
|
||||
vacuumWorkerActive, retrying = syncVacuumState(hasWorker, vacuumWorkerActive, toggler, retrying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadTaskConfigurationsFromPersistence loads saved task configurations from protobuf files
|
||||
func (s *AdminServer) loadTaskConfigurationsFromPersistence() {
|
||||
if s.configPersistence == nil || !s.configPersistence.IsConfigured() {
|
||||
@@ -1500,6 +1586,11 @@ func (s *AdminServer) UpdateTopicRetention(namespace, name string, enabled bool,
|
||||
func (s *AdminServer) Shutdown() {
|
||||
glog.V(1).Infof("Shutting down admin server...")
|
||||
|
||||
// Cancel background goroutines (vacuum monitor, etc.)
|
||||
if s.bgCancel != nil {
|
||||
s.bgCancel()
|
||||
}
|
||||
|
||||
// Stop maintenance manager
|
||||
s.StopMaintenanceManager()
|
||||
if s.adminPresenceLock != nil {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
|
||||
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
|
||||
)
|
||||
|
||||
// TestVacuumMonitorStateTransitions verifies the state transition logic that
|
||||
// drives the vacuum monitor goroutine: the plugin registry correctly reports
|
||||
// whether a vacuum-capable worker is present or absent.
|
||||
func TestVacuumMonitorStateTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := adminplugin.Options{
|
||||
DataDir: "",
|
||||
}
|
||||
p, err := adminplugin.New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create plugin: %v", err)
|
||||
}
|
||||
defer p.Shutdown()
|
||||
|
||||
// Initially no workers => no capable worker.
|
||||
if p.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected no capable worker initially")
|
||||
}
|
||||
|
||||
// Simulate state transition false -> true: worker connects.
|
||||
p.WorkerConnectForTest(&plugin_pb.WorkerHello{
|
||||
WorkerId: "vacuum-worker-1",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: true, CanExecute: true},
|
||||
},
|
||||
})
|
||||
|
||||
if !p.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected capable worker after connect")
|
||||
}
|
||||
|
||||
// Simulate state transition true -> false: worker disconnects.
|
||||
p.WorkerDisconnectForTest("vacuum-worker-1")
|
||||
|
||||
// Give registry removal a moment (it's synchronous, but be safe).
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if p.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected no capable worker after disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeToggler records disable/enable calls for testing.
|
||||
type fakeToggler struct {
|
||||
disableCalls int
|
||||
enableCalls int
|
||||
}
|
||||
|
||||
func (f *fakeToggler) disableVacuum() error {
|
||||
f.disableCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeToggler) enableVacuum() error {
|
||||
f.enableCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSyncVacuumState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("no change when state matches", func(t *testing.T) {
|
||||
tog := &fakeToggler{}
|
||||
result, failed := syncVacuumState(false, false, tog, false)
|
||||
if result != false || failed {
|
||||
t.Error("expected false, no failure")
|
||||
}
|
||||
result, failed = syncVacuumState(true, true, tog, false)
|
||||
if result != true || failed {
|
||||
t.Error("expected true, no failure")
|
||||
}
|
||||
if tog.disableCalls != 0 || tog.enableCalls != 0 {
|
||||
t.Errorf("expected no calls, got disable=%d enable=%d", tog.disableCalls, tog.enableCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("worker connects triggers disable", func(t *testing.T) {
|
||||
tog := &fakeToggler{}
|
||||
result, failed := syncVacuumState(true, false, tog, false)
|
||||
if result != true || failed {
|
||||
t.Error("expected true after disable, no failure")
|
||||
}
|
||||
if tog.disableCalls != 1 {
|
||||
t.Errorf("expected 1 disable call, got %d", tog.disableCalls)
|
||||
}
|
||||
if tog.enableCalls != 0 {
|
||||
t.Errorf("expected 0 enable calls, got %d", tog.enableCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("worker disconnects triggers enable", func(t *testing.T) {
|
||||
tog := &fakeToggler{}
|
||||
result, failed := syncVacuumState(false, true, tog, false)
|
||||
if result != false || failed {
|
||||
t.Error("expected false after enable, no failure")
|
||||
}
|
||||
if tog.enableCalls != 1 {
|
||||
t.Errorf("expected 1 enable call, got %d", tog.enableCalls)
|
||||
}
|
||||
if tog.disableCalls != 0 {
|
||||
t.Errorf("expected 0 disable calls, got %d", tog.disableCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,8 @@ func (h *PluginHandlers) ShowPluginMonitoring(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (h *PluginHandlers) renderPluginPage(w http.ResponseWriter, r *http.Request, page string) {
|
||||
component := app.Plugin(page)
|
||||
initialJob := r.URL.Query().Get("job")
|
||||
component := app.Plugin(page, initialJob)
|
||||
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
|
||||
layoutComponent := layout.Layout(viewCtx, component)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ type Plugin struct {
|
||||
schedulerRun map[string]*schedulerRunInfo
|
||||
schedulerLoopMu sync.Mutex
|
||||
schedulerLoopState schedulerLoopState
|
||||
schedulerWakeCh chan struct{}
|
||||
schedulerWakeCh chan struct{}
|
||||
|
||||
dedupeMu sync.Mutex
|
||||
recentDedupeByType map[string]map[string]time.Time
|
||||
@@ -392,7 +392,6 @@ func (r *Plugin) SaveJobTypeConfig(config *plugin_pb.PersistedJobTypeConfig) err
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Plugin) LoadDescriptor(jobType string) (*plugin_pb.JobTypeDescriptor, error) {
|
||||
return r.store.LoadDescriptor(jobType)
|
||||
}
|
||||
@@ -688,6 +687,11 @@ func (r *Plugin) executeJobWithExecutor(
|
||||
}
|
||||
}
|
||||
|
||||
// HasCapableWorker checks if any non-stale worker has a capability for the given job type.
|
||||
func (r *Plugin) HasCapableWorker(jobType string) bool {
|
||||
return r.registry.HasCapableWorker(jobType)
|
||||
}
|
||||
|
||||
func (r *Plugin) ListWorkers() []*WorkerSession {
|
||||
return r.registry.List()
|
||||
}
|
||||
@@ -1414,3 +1418,13 @@ func (s *streamSession) close() {
|
||||
close(s.outgoing)
|
||||
})
|
||||
}
|
||||
|
||||
// WorkerConnectForTest simulates a worker connecting (test helper).
|
||||
func (r *Plugin) WorkerConnectForTest(hello *plugin_pb.WorkerHello) {
|
||||
r.registry.UpsertFromHello(hello)
|
||||
}
|
||||
|
||||
// WorkerDisconnectForTest simulates a worker disconnecting (test helper).
|
||||
func (r *Plugin) WorkerDisconnectForTest(workerID string) {
|
||||
r.registry.Remove(workerID)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
defaultClusterContextTimeout = 10 * time.Second
|
||||
defaultWaitingBacklogFloor = 8
|
||||
defaultWaitingBacklogMultiplier = 4
|
||||
maxEstimatedRuntimeCap = 8 * time.Hour
|
||||
)
|
||||
|
||||
type schedulerPolicy struct {
|
||||
@@ -293,6 +294,26 @@ func (r *Plugin) runJobTypeIteration(jobType string, policy schedulerPolicy) boo
|
||||
|
||||
r.setSchedulerLoopState(jobType, "executing")
|
||||
|
||||
// Scan proposals for the maximum estimated_runtime_seconds so the
|
||||
// execution phase gets enough time for large jobs (e.g. vacuum on
|
||||
// big volumes). If any proposal needs more time than the remaining
|
||||
// JobTypeMaxRuntime, extend the execution context accordingly.
|
||||
var maxEstimatedRuntime time.Duration
|
||||
for _, p := range filtered {
|
||||
if p.Parameters != nil {
|
||||
if est, ok := p.Parameters["estimated_runtime_seconds"]; ok {
|
||||
if v := est.GetInt64Value(); v > 0 {
|
||||
if d := time.Duration(v) * time.Second; d > maxEstimatedRuntime {
|
||||
maxEstimatedRuntime = d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxEstimatedRuntime > maxEstimatedRuntimeCap {
|
||||
maxEstimatedRuntime = maxEstimatedRuntimeCap
|
||||
}
|
||||
|
||||
remaining = time.Until(start.Add(maxRuntime))
|
||||
if remaining <= 0 {
|
||||
r.appendActivity(JobActivity{
|
||||
@@ -306,6 +327,17 @@ func (r *Plugin) runJobTypeIteration(jobType string, policy schedulerPolicy) boo
|
||||
return detected
|
||||
}
|
||||
|
||||
// If the longest estimated job exceeds the remaining JobTypeMaxRuntime,
|
||||
// create a new execution context with enough headroom instead of using
|
||||
// jobCtx which would cancel too early.
|
||||
execCtx := jobCtx
|
||||
execCancel := context.CancelFunc(func() {})
|
||||
if maxEstimatedRuntime > 0 && maxEstimatedRuntime > remaining {
|
||||
execCtx, execCancel = context.WithTimeout(context.Background(), maxEstimatedRuntime)
|
||||
remaining = maxEstimatedRuntime
|
||||
}
|
||||
defer execCancel()
|
||||
|
||||
execPolicy := policy
|
||||
if execPolicy.ExecutionTimeout <= 0 {
|
||||
execPolicy.ExecutionTimeout = defaultScheduledExecutionTimeout
|
||||
@@ -314,10 +346,10 @@ func (r *Plugin) runJobTypeIteration(jobType string, policy schedulerPolicy) boo
|
||||
execPolicy.ExecutionTimeout = remaining
|
||||
}
|
||||
|
||||
successCount, errorCount, canceledCount := r.dispatchScheduledProposals(jobCtx, jobType, filtered, clusterContext, execPolicy)
|
||||
successCount, errorCount, canceledCount := r.dispatchScheduledProposals(execCtx, jobType, filtered, clusterContext, execPolicy)
|
||||
|
||||
status := "success"
|
||||
if jobCtx.Err() != nil {
|
||||
if execCtx.Err() != nil {
|
||||
status = "timeout"
|
||||
} else if errorCount > 0 || canceledCount > 0 {
|
||||
status = "error"
|
||||
@@ -937,7 +969,24 @@ func (r *Plugin) executeScheduledJobWithExecutor(
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
execCtx, cancel := context.WithTimeout(parent, policy.ExecutionTimeout)
|
||||
// Use the job's estimated runtime if provided and larger than the
|
||||
// default execution timeout. This lets handlers like vacuum scale
|
||||
// the timeout based on volume size so large volumes are not killed.
|
||||
timeout := policy.ExecutionTimeout
|
||||
if job.Parameters != nil {
|
||||
if est, ok := job.Parameters["estimated_runtime_seconds"]; ok {
|
||||
if v := est.GetInt64Value(); v > 0 {
|
||||
estimated := time.Duration(v) * time.Second
|
||||
if estimated > maxEstimatedRuntimeCap {
|
||||
estimated = maxEstimatedRuntimeCap
|
||||
}
|
||||
if estimated > timeout {
|
||||
timeout = estimated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
execCtx, cancel := context.WithTimeout(parent, timeout)
|
||||
_, err := r.executeJobWithExecutor(execCtx, executor, job, clusterContext, int32(attempt))
|
||||
cancel()
|
||||
if err == nil {
|
||||
|
||||
@@ -120,6 +120,28 @@ func (r *Registry) List() []*WorkerSession {
|
||||
return out
|
||||
}
|
||||
|
||||
// HasCapableWorker checks if any non-stale worker session has a capability for the given job type.
|
||||
// A worker is capable if its capabilities include the job type with CanDetect or CanExecute true.
|
||||
func (r *Registry) HasCapableWorker(jobType string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
for _, session := range r.sessions {
|
||||
if r.isSessionStaleLocked(session, now) {
|
||||
continue
|
||||
}
|
||||
capability := session.Capabilities[jobType]
|
||||
if capability == nil {
|
||||
continue
|
||||
}
|
||||
if capability.CanDetect || capability.CanExecute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DetectableJobTypes returns sorted job types that currently have at least one detect-capable worker.
|
||||
func (r *Registry) DetectableJobTypes() []string {
|
||||
r.mu.RLock()
|
||||
|
||||
@@ -319,3 +319,85 @@ func TestRegistryReturnsNoDetectorWhenAllWorkersStale(t *testing.T) {
|
||||
t.Fatalf("expected no detector when all workers are stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerWithDetectCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-a",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: true, CanExecute: false},
|
||||
},
|
||||
})
|
||||
|
||||
if !r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return true for detect-capable worker")
|
||||
}
|
||||
if r.HasCapableWorker("balance") {
|
||||
t.Fatalf("expected HasCapableWorker to return false for unknown job type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerWithExecuteCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-a",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: false, CanExecute: true},
|
||||
},
|
||||
})
|
||||
|
||||
if !r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return true for execute-capable worker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerReturnsFalseWhenNoWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
if r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return false with no workers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerSkipsStaleWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.staleAfter = 2 * time.Second
|
||||
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-stale",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: true, CanExecute: true},
|
||||
},
|
||||
})
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions["worker-stale"].LastSeenAt = time.Now().Add(-10 * time.Second)
|
||||
r.mu.Unlock()
|
||||
|
||||
if r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return false when all workers are stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerIgnoresNilCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-a",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: false, CanExecute: false},
|
||||
},
|
||||
})
|
||||
|
||||
if r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return false when capability has no detect or execute")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ func CalculateTaskStorageImpact(taskType TaskType, volumeSize int64) (sourceChan
|
||||
// Replication task: creates new replica on target
|
||||
return StorageSlotChange{VolumeSlots: 0, ShardSlots: 0}, StorageSlotChange{VolumeSlots: 1, ShardSlots: 0}
|
||||
|
||||
case TaskTypeECBalance:
|
||||
// EC balance task: moves individual shard from source to target
|
||||
// Source frees 1 shard slot, target gains 1 shard slot
|
||||
return StorageSlotChange{VolumeSlots: 0, ShardSlots: -1}, StorageSlotChange{VolumeSlots: 0, ShardSlots: 1}
|
||||
|
||||
default:
|
||||
// Unknown task type, assume minimal impact
|
||||
glog.Warningf("unhandled task type %s in CalculateTaskStorageImpact, assuming default impact", taskType)
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
TaskTypeBalance TaskType = "balance"
|
||||
TaskTypeErasureCoding TaskType = "erasure_coding"
|
||||
TaskTypeReplication TaskType = "replication"
|
||||
TaskTypeECBalance TaskType = "ec_balance"
|
||||
)
|
||||
|
||||
// Common task status constants
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package app
|
||||
|
||||
templ Plugin(page string) {
|
||||
templ Plugin(page string, initialJob string) {
|
||||
{{
|
||||
currentPage := page
|
||||
if currentPage == "" {
|
||||
currentPage = "overview"
|
||||
}
|
||||
}}
|
||||
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage }>
|
||||
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage } data-plugin-job={ initialJob }>
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
@@ -239,7 +239,7 @@ templ Plugin(page string) {
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-cogs me-2"></i>Job Scheduling Settings</h5>
|
||||
</div>
|
||||
@@ -589,6 +589,7 @@ templ Plugin(page string) {
|
||||
jobTypes: [],
|
||||
lastDetectionByJobType: {},
|
||||
initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),
|
||||
initialJob: String(page.getAttribute('data-plugin-job') || '').trim(),
|
||||
activeTopTab: 'overview',
|
||||
activeSubTab: 'configuration',
|
||||
initialNavigationApplied: false,
|
||||
@@ -717,6 +718,28 @@ templ Plugin(page string) {
|
||||
return 'configuration';
|
||||
}
|
||||
|
||||
function buildPluginURL(topTab, subTab, jobType) {
|
||||
if (topTab === 'overview') {
|
||||
return '/plugin';
|
||||
}
|
||||
var effectiveSubTab = normalizeSubTab(subTab);
|
||||
var path = '/plugin/' + encodeURIComponent(effectiveSubTab);
|
||||
var jt = String(jobType || '').trim();
|
||||
if (jt) {
|
||||
path += '?job=' + encodeURIComponent(jt);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function updateURL(replace) {
|
||||
var url = buildPluginURL(state.activeTopTab, state.activeSubTab, state.selectedJobType);
|
||||
if (replace) {
|
||||
history.replaceState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
|
||||
} else {
|
||||
history.pushState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a job type item (string or object) into a safe object shape
|
||||
function parseJobTypeItem(item) {
|
||||
var jobType = '';
|
||||
@@ -782,8 +805,13 @@ templ Plugin(page string) {
|
||||
state.activeTopTab = 'overview';
|
||||
return;
|
||||
}
|
||||
var firstItem = parseJobTypeItem(state.jobTypes[0]);
|
||||
state.selectedJobType = firstItem.jobType;
|
||||
// If a specific job type was provided in the URL, use it
|
||||
if (state.initialJob && hasJobType(state.initialJob)) {
|
||||
state.selectedJobType = state.initialJob;
|
||||
} else {
|
||||
var firstItem = parseJobTypeItem(state.jobTypes[0]);
|
||||
state.selectedJobType = firstItem.jobType;
|
||||
}
|
||||
state.activeTopTab = topTabKeyForJobType(state.selectedJobType);
|
||||
}
|
||||
|
||||
@@ -3015,6 +3043,7 @@ templ Plugin(page string) {
|
||||
if (topTabKey === 'overview') {
|
||||
state.activeTopTab = 'overview';
|
||||
renderNavigationState();
|
||||
updateURL(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3026,6 +3055,7 @@ templ Plugin(page string) {
|
||||
state.selectedJobType = jobType;
|
||||
ensureActiveNavigation();
|
||||
renderNavigationState();
|
||||
updateURL(false);
|
||||
if (state.loadedJobType !== jobType) {
|
||||
try {
|
||||
await loadDescriptorAndConfig(jobType, false);
|
||||
@@ -3053,6 +3083,7 @@ templ Plugin(page string) {
|
||||
var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));
|
||||
state.activeSubTab = subTabKey;
|
||||
renderNavigationState();
|
||||
updateURL(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3076,11 +3107,44 @@ templ Plugin(page string) {
|
||||
renderNavigationState();
|
||||
await refreshAll();
|
||||
|
||||
// Set the initial browser history state to match the current view
|
||||
updateURL(true);
|
||||
|
||||
state.refreshTimer = setInterval(function() {
|
||||
refreshAll();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', async function(event) {
|
||||
var s = event.state;
|
||||
if (!s || !s.pluginTopTab) {
|
||||
// No plugin state — treat as overview
|
||||
state.activeTopTab = 'overview';
|
||||
renderNavigationState();
|
||||
return;
|
||||
}
|
||||
state.activeTopTab = String(s.pluginTopTab || 'overview');
|
||||
state.activeSubTab = normalizeSubTab(s.pluginSubTab);
|
||||
var jobType = String(s.pluginJob || '').trim();
|
||||
if (jobType) {
|
||||
state.selectedJobType = jobType;
|
||||
}
|
||||
ensureActiveNavigation();
|
||||
renderNavigationState();
|
||||
// If a job type is selected and not loaded yet, load it
|
||||
if (state.selectedJobType && state.loadedJobType !== state.selectedJobType) {
|
||||
try {
|
||||
await loadDescriptorAndConfig(state.selectedJobType, false);
|
||||
} catch (e) {
|
||||
console.error('Failed to load data on popstate navigation:', e);
|
||||
}
|
||||
}
|
||||
renderQueueJobs();
|
||||
renderDetectionJobs();
|
||||
renderExecutionJobs();
|
||||
renderExecutionActivities();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (state.refreshTimer) {
|
||||
clearInterval(state.refreshTimer);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -269,50 +269,6 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
if currentPath == "/plugin/detection" {
|
||||
<a class="nav-link active" href="/plugin/detection">
|
||||
<i class="fas fa-search me-2"></i>Job Detection
|
||||
</a>
|
||||
} else {
|
||||
<a class="nav-link" href="/plugin/detection">
|
||||
<i class="fas fa-search me-2"></i>Job Detection
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
if currentPath == "/plugin/queue" {
|
||||
<a class="nav-link active" href="/plugin/queue">
|
||||
<i class="fas fa-list me-2"></i>Job Queue
|
||||
</a>
|
||||
} else {
|
||||
<a class="nav-link" href="/plugin/queue">
|
||||
<i class="fas fa-list me-2"></i>Job Queue
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
if currentPath == "/plugin/execution" {
|
||||
<a class="nav-link active" href="/plugin/execution">
|
||||
<i class="fas fa-tasks me-2"></i>Job Execution
|
||||
</a>
|
||||
} else {
|
||||
<a class="nav-link" href="/plugin/execution">
|
||||
<i class="fas fa-tasks me-2"></i>Job Execution
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
if currentPath == "/plugin/configuration" {
|
||||
<a class="nav-link active" href="/plugin/configuration">
|
||||
<i class="fas fa-sliders-h me-2"></i>Configuration
|
||||
</a>
|
||||
} else {
|
||||
<a class="nav-link" href="/plugin/configuration">
|
||||
<i class="fas fa-sliders-h me-2"></i>Configuration
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,6 +290,9 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
<div class="container-fluid text-center">
|
||||
<small class="text-muted">
|
||||
© {fmt.Sprintf("%d", time.Now().Year())} SeaweedFS Admin v{version.VERSION_NUMBER}
|
||||
if version.COMMIT != "" {
|
||||
<span class="mx-1">({version.COMMIT})</span>
|
||||
}
|
||||
if !strings.Contains(version.VERSION, "enterprise") {
|
||||
<span class="mx-2">•</span>
|
||||
<a href="https://seaweedfs.com" target="_blank" class="text-decoration-none">
|
||||
|
||||
@@ -269,67 +269,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</li><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentPath == "/plugin/detection" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"nav-link active\" href=\"/plugin/detection\"><i class=\"fas fa-search me-2\"></i>Job Detection</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a class=\"nav-link\" href=\"/plugin/detection\"><i class=\"fas fa-search me-2\"></i>Job Detection</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</li><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentPath == "/plugin/queue" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a class=\"nav-link active\" href=\"/plugin/queue\"><i class=\"fas fa-list me-2\"></i>Job Queue</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"nav-link\" href=\"/plugin/queue\"><i class=\"fas fa-list me-2\"></i>Job Queue</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</li><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentPath == "/plugin/execution" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a class=\"nav-link active\" href=\"/plugin/execution\"><i class=\"fas fa-tasks me-2\"></i>Job Execution</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<a class=\"nav-link\" href=\"/plugin/execution\"><i class=\"fas fa-tasks me-2\"></i>Job Execution</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</li><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentPath == "/plugin/configuration" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a class=\"nav-link active\" href=\"/plugin/configuration\"><i class=\"fas fa-sliders-h me-2\"></i>Configuration</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"nav-link\" href=\"/plugin/configuration\"><i class=\"fas fa-sliders-h me-2\"></i>Configuration</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</li></ul></div></div><!-- Sidebar backdrop for mobile --><div class=\"sidebar-backdrop\" id=\"sidebarBackdrop\"></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-3 px-md-4\"><div class=\"pt-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</li></ul></div></div><!-- Sidebar backdrop for mobile --><div class=\"sidebar-backdrop\" id=\"sidebarBackdrop\"></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-3 px-md-4\"><div class=\"pt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -337,43 +277,62 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></main></div></div><!-- Footer --><footer class=\"footer mt-auto py-3 bg-light\"><div class=\"container-fluid text-center\"><small class=\"text-muted\">© ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div></main></div></div><!-- Footer --><footer class=\"footer mt-auto py-3 bg-light\"><div class=\"container-fluid text-center\"><small class=\"text-muted\">© ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 336, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 292, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " SeaweedFS Admin v")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " SeaweedFS Admin v")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 336, Col: 102}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 292, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !strings.Contains(version.VERSION, "enterprise") {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
|
||||
if version.COMMIT != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"mx-1\">(")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.COMMIT)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 294, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</small></div></footer><!-- Bootstrap JS --><script src=\"/static/js/bootstrap.bundle.min.js\"></script><!-- Modal Alerts JS (replaces native alert/confirm) --><script src=\"/static/js/modal-alerts.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script><script src=\"/static/js/iam-utils.js\"></script><script src=\"/static/js/s3tables.js\"></script></body></html>")
|
||||
if !strings.Contains(version.VERSION, "enterprise") {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</small></div></footer><!-- Bootstrap JS --><script src=\"/static/js/bootstrap.bundle.min.js\"></script><!-- Modal Alerts JS (replaces native alert/confirm) --><script src=\"/static/js/modal-alerts.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script><script src=\"/static/js/iam-utils.js\"></script><script src=\"/static/js/s3tables.js\"></script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -397,74 +356,74 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var16 == nil {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
templ_7745c5c3_Var17 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var17 == nil {
|
||||
templ_7745c5c3_Var17 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 364, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 378, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 323, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 344, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form method=\"POST\" action=\"/login\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<form method=\"POST\" action=\"/login\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 390, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 349, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"/static/js/bootstrap.bundle.min.js\"></script></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"/static/js/bootstrap.bundle.min.js\"></script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/spf13/viper"
|
||||
@@ -98,6 +100,9 @@ var cmdAdmin = &Command{
|
||||
- IMPORTANT: When read-only credentials are configured, adminPassword MUST also be set
|
||||
- This ensures an admin account exists to manage and authorize read-only access
|
||||
- Sessions are secured with auto-generated session keys
|
||||
- Credentials can also be set via security.toml [admin] section or environment variables:
|
||||
WEED_ADMIN_USER, WEED_ADMIN_PASSWORD, WEED_ADMIN_READONLY_USER, WEED_ADMIN_READONLY_PASSWORD
|
||||
- Precedence: CLI flag > env var / security.toml > default value
|
||||
|
||||
Security Configuration:
|
||||
- The admin server reads TLS configuration from security.toml
|
||||
@@ -137,6 +142,13 @@ func runAdmin(cmd *Command, args []string) bool {
|
||||
// Load security configuration
|
||||
util.LoadSecurityConfiguration()
|
||||
|
||||
// Apply security.toml / env var fallbacks for credential flags.
|
||||
// CLI flags take precedence over security.toml / WEED_* env vars.
|
||||
applyViperFallback(cmd, a.adminUser, "adminUser", "admin.user")
|
||||
applyViperFallback(cmd, a.adminPassword, "adminPassword", "admin.password")
|
||||
applyViperFallback(cmd, a.readOnlyUser, "readOnlyUser", "admin.readonly.user")
|
||||
applyViperFallback(cmd, a.readOnlyPassword, "readOnlyPassword", "admin.readonly.password")
|
||||
|
||||
// Backward compatibility: if -masters is provided, use it
|
||||
if *a.masters != "" {
|
||||
*a.master = *a.masters
|
||||
@@ -534,6 +546,22 @@ func loadOrGenerateSessionKeys(dataDir string) ([]byte, []byte, error) {
|
||||
return key[:keyLen], key[keyLen:], nil
|
||||
}
|
||||
|
||||
// applyViperFallback sets a flag's value from viper (security.toml / env var)
|
||||
// when the flag was not explicitly set on the command line.
|
||||
func applyViperFallback(cmd *Command, flagPtr *string, flagName, viperKey string) {
|
||||
explicitlySet := false
|
||||
cmd.Flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == flagName {
|
||||
explicitlySet = true
|
||||
}
|
||||
})
|
||||
if !explicitlySet {
|
||||
if v := util.GetViper().GetString(viperKey); v != "" {
|
||||
*flagPtr = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandHomeDir expands the tilde (~) in a path to the user's home directory
|
||||
func expandHomeDir(path string) (string, error) {
|
||||
if path == "" {
|
||||
|
||||
+22
-8
@@ -45,7 +45,7 @@ const (
|
||||
defaultMiniVolumeSizeMB = 128 // Default volume size for mini mode
|
||||
maxVolumeSizeMB = 1024 // Maximum volume size in MB (1GB)
|
||||
GrpcPortOffset = 10000 // Offset used to calculate gRPC port from HTTP port
|
||||
defaultMiniPluginJobTypes = "vacuum,volume_balance,erasure_coding,admin_script"
|
||||
defaultMiniPluginJobTypes = "all"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -114,7 +114,7 @@ Admin UI (http://localhost:23646) to manage users and policies.
|
||||
|
||||
var (
|
||||
miniIp = cmdMini.Flag.String("ip", util.DetectedHostAddress(), "ip or server name, also used as identifier")
|
||||
miniBindIp = cmdMini.Flag.String("ip.bind", "", "ip address to bind to. If empty, default to same as -ip option.")
|
||||
miniBindIp = cmdMini.Flag.String("ip.bind", "0.0.0.0", "ip address to bind to. If empty, default to same as -ip option.")
|
||||
miniTimeout = cmdMini.Flag.Int("idleTimeout", 30, "connection idle seconds")
|
||||
miniDataCenter = cmdMini.Flag.String("dataCenter", "", "current volume server's data center name")
|
||||
miniRack = cmdMini.Flag.String("rack", "", "current volume server's rack name")
|
||||
@@ -555,9 +555,23 @@ func ensureAllPortsAvailableOnIP(bindIp string) error {
|
||||
// If a gRPC port is 0, it will be set to httpPort + GrpcPortOffset
|
||||
// This must be called after HTTP ports are finalized and before services start
|
||||
func initializeGrpcPortsOnIP(bindIp string) {
|
||||
// Track gRPC ports allocated during this function to prevent collisions between services
|
||||
// when multiple services need fallback port allocation
|
||||
allocatedGrpcPorts := make(map[int]bool)
|
||||
// Track all ports allocated (both HTTP and gRPC) to prevent collisions.
|
||||
// We must reserve HTTP ports so that gRPC fallback allocation never picks
|
||||
// a port already assigned to an HTTP service (which hasn't bound yet).
|
||||
allocatedPorts := make(map[int]bool)
|
||||
|
||||
// Reserve all HTTP ports first
|
||||
allocatedPorts[*miniMasterOptions.port] = true
|
||||
allocatedPorts[*miniFilerOptions.port] = true
|
||||
allocatedPorts[*miniOptions.v.port] = true
|
||||
allocatedPorts[*miniWebDavOptions.port] = true
|
||||
allocatedPorts[*miniAdminOptions.port] = true
|
||||
if *miniEnableS3 {
|
||||
allocatedPorts[*miniS3Options.port] = true
|
||||
if miniS3Options.portIceberg != nil && *miniS3Options.portIceberg > 0 {
|
||||
allocatedPorts[*miniS3Options.portIceberg] = true
|
||||
}
|
||||
}
|
||||
|
||||
grpcConfigs := []struct {
|
||||
httpPort *int
|
||||
@@ -593,10 +607,10 @@ func initializeGrpcPortsOnIP(bindIp string) {
|
||||
|
||||
// Verify the gRPC port is available (whether calculated or explicitly set)
|
||||
// Check on both specific IP and all interfaces, and check against already allocated ports
|
||||
if !isPortOpenOnIP(bindIp, *config.grpcPort) || !isPortAvailable(*config.grpcPort) || allocatedGrpcPorts[*config.grpcPort] {
|
||||
if !isPortOpenOnIP(bindIp, *config.grpcPort) || !isPortAvailable(*config.grpcPort) || allocatedPorts[*config.grpcPort] {
|
||||
glog.Warningf("gRPC port %d for %s is not available, finding alternative...", *config.grpcPort, config.name)
|
||||
originalPort := *config.grpcPort
|
||||
newPort := findAvailablePortOnIP(bindIp, originalPort+1, 100, allocatedGrpcPorts)
|
||||
newPort := findAvailablePortOnIP(bindIp, originalPort+1, 100, allocatedPorts)
|
||||
if newPort == 0 {
|
||||
glog.Errorf("Could not find available gRPC port for %s starting from %d, will use %d and fail on binding", config.name, originalPort+1, originalPort)
|
||||
} else {
|
||||
@@ -604,7 +618,7 @@ func initializeGrpcPortsOnIP(bindIp string) {
|
||||
*config.grpcPort = newPort
|
||||
}
|
||||
}
|
||||
allocatedGrpcPorts[*config.grpcPort] = true
|
||||
allocatedPorts[*config.grpcPort] = true
|
||||
glog.V(1).Infof("%s gRPC port set to %d", config.name, *config.grpcPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
|
||||
func TestMiniDefaultPluginJobTypes(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
// defaultMiniPluginJobTypes is an explicit list: "vacuum,volume_balance,erasure_coding,admin_script"
|
||||
// defaultMiniPluginJobTypes is "all", which includes every registered handler
|
||||
handlers, err := buildPluginWorkerHandlers(defaultMiniPluginJobTypes, dialOption, int(pluginworker.DefaultMaxExecutionConcurrency), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(mini default) err = %v", err)
|
||||
}
|
||||
if len(handlers) != 4 {
|
||||
t.Fatalf("expected mini default job types to include 4 handlers, got %d", len(handlers))
|
||||
if len(handlers) != 6 {
|
||||
t.Fatalf("expected mini default job types to include 6 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,19 @@ cert = ""
|
||||
key = ""
|
||||
ca = ""
|
||||
|
||||
# Admin server authentication
|
||||
# If password is set, users must login to access the admin interface
|
||||
# These can be overridden by environment variables with WEED_ prefix:
|
||||
# WEED_ADMIN_USER, WEED_ADMIN_PASSWORD
|
||||
# WEED_ADMIN_READONLY_USER, WEED_ADMIN_READONLY_PASSWORD
|
||||
[admin]
|
||||
user = ""
|
||||
password = ""
|
||||
|
||||
[admin.readonly]
|
||||
user = ""
|
||||
password = ""
|
||||
|
||||
# white list. It's checking request ip address.
|
||||
[guard]
|
||||
white_list = ""
|
||||
|
||||
@@ -351,7 +351,7 @@ func parseVolumeTags(tagsArg string, folderCount int) [][]string {
|
||||
tagEntries = strings.Split(tagsArg, ",")
|
||||
}
|
||||
folderTags := make([][]string, folderCount)
|
||||
|
||||
|
||||
// If exactly one tag entry provided, replicate it to all folders
|
||||
if len(tagEntries) == 1 {
|
||||
normalized := util.NormalizeTagList(strings.Split(tagEntries[0], ":"))
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestWorkerDefaultJobTypes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(default worker flag) err = %v", err)
|
||||
}
|
||||
// Expected: vacuum, volume_balance, admin_script, erasure_coding, iceberg_maintenance
|
||||
if len(handlers) != 5 {
|
||||
t.Fatalf("expected default worker job types to include 5 handlers, got %d", len(handlers))
|
||||
// Expected: vacuum, volume_balance, admin_script, erasure_coding, iceberg_maintenance, ec_balance
|
||||
if len(handlers) != 6 {
|
||||
t.Fatalf("expected default worker job types to include 6 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type Filer struct {
|
||||
FilerConf *FilerConf
|
||||
RemoteStorage *FilerRemoteStorage
|
||||
lazyFetchGroup singleflight.Group
|
||||
lazyListGroup singleflight.Group
|
||||
Dlm *lock_manager.DistributedLockManager
|
||||
MaxFilenameLength uint32
|
||||
deletionQuit chan struct{}
|
||||
@@ -389,6 +390,8 @@ func (f *Filer) FindEntry(ctx context.Context, p util.FullPath) (entry *Entry, e
|
||||
}
|
||||
|
||||
func (f *Filer) doListDirectoryEntries(ctx context.Context, p util.FullPath, startFileName string, inclusive bool, limit int64, prefix string, eachEntryFunc ListEachEntryFunc) (expiredCount int64, lastFileName string, err error) {
|
||||
f.maybeLazyListFromRemote(ctx, p)
|
||||
|
||||
// Collect expired entries during iteration to avoid deadlock with DB connection pool
|
||||
var expiredEntries []*Entry
|
||||
var s3ExpiredEntries []*Entry
|
||||
|
||||
@@ -91,8 +91,19 @@ func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry
|
||||
lastFileName = sub.Name()
|
||||
if sub.IsDirectory() {
|
||||
subIsDeletingBucket := f.IsBucket(sub)
|
||||
err = f.doBatchDeleteFolderMetaAndData(ctx, sub, isRecursive, ignoreRecursiveError, shouldDeleteChunks, subIsDeletingBucket, false, nil, onHardLinkIdsFn)
|
||||
err = f.doBatchDeleteFolderMetaAndData(ctx, sub, isRecursive, ignoreRecursiveError, shouldDeleteChunks, subIsDeletingBucket, isFromOtherCluster, nil, onHardLinkIdsFn)
|
||||
} else {
|
||||
if !isFromOtherCluster {
|
||||
if _, remoteErr := f.maybeDeleteFromRemote(ctx, sub); remoteErr != nil {
|
||||
glog.Warningf("remote delete child %s: %v", sub.FullPath, remoteErr)
|
||||
if !ignoreRecursiveError {
|
||||
err = remoteErr
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil && !ignoreRecursiveError {
|
||||
break
|
||||
}
|
||||
f.NotifyUpdateEvent(ctx, sub, nil, shouldDeleteChunks, isFromOtherCluster, nil)
|
||||
if len(sub.HardLinkId) != 0 {
|
||||
// hard link chunk data are deleted separately
|
||||
@@ -130,9 +141,16 @@ func (f *Filer) doDeleteEntryMetaAndData(ctx context.Context, entry *Entry, shou
|
||||
|
||||
glog.V(3).InfofCtx(ctx, "deleting entry %v, delete chunks: %v", entry.FullPath, shouldDeleteChunks)
|
||||
|
||||
if !isFromOtherCluster {
|
||||
if _, remoteDeletionErr := f.maybeDeleteFromRemote(ctx, entry); remoteDeletionErr != nil {
|
||||
return remoteDeletionErr
|
||||
}
|
||||
}
|
||||
|
||||
if storeDeletionErr := f.Store.DeleteOneEntry(ctx, entry); storeDeletionErr != nil {
|
||||
return fmt.Errorf("filer store delete: %w", storeDeletionErr)
|
||||
}
|
||||
|
||||
if !entry.IsDirectory() {
|
||||
f.NotifyUpdateEvent(ctx, entry, nil, shouldDeleteChunks, isFromOtherCluster, signatures)
|
||||
}
|
||||
|
||||
@@ -114,3 +114,49 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
|
||||
}
|
||||
return result.entry, nil
|
||||
}
|
||||
|
||||
func (f *Filer) maybeDeleteFromRemote(ctx context.Context, entry *Entry) (bool, error) {
|
||||
if entry == nil || f.RemoteStorage == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(entry.FullPath)
|
||||
if remoteLoc == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
client, _, found := f.RemoteStorage.GetRemoteStorageClient(remoteLoc.Name)
|
||||
if !found {
|
||||
return false, fmt.Errorf("resolve remote storage client for %s: not found", entry.FullPath)
|
||||
}
|
||||
if client == nil {
|
||||
return false, fmt.Errorf("resolve remote storage client for %s: initialization failed", entry.FullPath)
|
||||
}
|
||||
|
||||
objectLoc := MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, entry.FullPath)
|
||||
|
||||
if entry.IsDirectory() {
|
||||
if err := client.RemoveDirectory(objectLoc); err != nil {
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("remove remote directory %s: %w", entry.FullPath, err)
|
||||
}
|
||||
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted directory %s from remote", entry.FullPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if entry.Remote == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := client.DeleteFile(objectLoc); err != nil {
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("delete remote file %s: %w", entry.FullPath, err)
|
||||
}
|
||||
|
||||
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted %s from remote", entry.FullPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const xattrRemoteListingSyncedAt = "remote.listing.synced_at"
|
||||
|
||||
type lazyListContextKey struct{}
|
||||
|
||||
// maybeLazyListFromRemote populates the local filer store with entries from the
|
||||
// remote storage backend for directory p if the following conditions hold:
|
||||
// - p is under a remote mount with listing_cache_ttl_seconds > 0
|
||||
// - the cached listing has expired (based on the per-mount TTL)
|
||||
//
|
||||
// When listing_cache_ttl_seconds is 0 (the default), lazy listing is disabled
|
||||
// for that mount.
|
||||
//
|
||||
// On success it updates the directory's xattrRemoteListingSyncedAt extended
|
||||
// attribute so subsequent calls within the TTL window are no-ops.
|
||||
//
|
||||
// Errors are logged and swallowed (availability over consistency).
|
||||
func (f *Filer) maybeLazyListFromRemote(ctx context.Context, p util.FullPath) {
|
||||
// Prevent recursion: CreateEntry → FindEntry → doListDirectoryEntries → here
|
||||
if ctx.Value(lazyListContextKey{}) != nil {
|
||||
return
|
||||
}
|
||||
// Also respect the lazy-fetch guard to prevent mutual recursion
|
||||
if ctx.Value(lazyFetchContextKey{}) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.RemoteStorage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// The ptrie stores mount rules with trailing "/". When p is exactly the
|
||||
// mount directory (e.g. "/buckets/mybucket"), we must also try matching
|
||||
// with a trailing "/" so the trie recognizes the mount root.
|
||||
lookupPath := p
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(lookupPath)
|
||||
if remoteLoc == nil {
|
||||
lookupPath = util.FullPath(string(p) + "/")
|
||||
mountDir, remoteLoc = f.RemoteStorage.FindMountDirectory(lookupPath)
|
||||
if remoteLoc == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy listing is opt-in: disabled when TTL is 0
|
||||
if remoteLoc.ListingCacheTtlSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
cacheTTL := time.Duration(remoteLoc.ListingCacheTtlSeconds) * time.Second
|
||||
|
||||
// Check staleness: read the directory entry's extended attributes.
|
||||
// Use Store.FindEntry directly — we only need the local xattr, not lazy-fetch.
|
||||
dirEntry, _ := f.Store.FindEntry(ctx, p)
|
||||
if dirEntry != nil {
|
||||
if syncedAtStr, ok := dirEntry.Extended[xattrRemoteListingSyncedAt]; ok {
|
||||
if syncedAt, err := strconv.ParseInt(string(syncedAtStr), 10, 64); err == nil {
|
||||
if time.Since(time.Unix(syncedAt, 0)) < cacheTTL {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client, _, found := f.RemoteStorage.FindRemoteStorageClient(lookupPath)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
key := "list:" + string(p)
|
||||
f.lazyListGroup.Do(key, func() (interface{}, error) {
|
||||
startTime := time.Now()
|
||||
objectLoc := MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, p)
|
||||
|
||||
// Decouple from the caller's cancellation/deadline while preserving
|
||||
// trace/span values for distributed tracing.
|
||||
persistCtx := context.WithValue(context.WithoutCancel(ctx), lazyListContextKey{}, true)
|
||||
persistCtx = context.WithValue(persistCtx, lazyFetchContextKey{}, true)
|
||||
|
||||
listErr := client.ListDirectory(persistCtx, objectLoc, func(dir string, name string, isDirectory bool, remoteEntry *filer_pb.RemoteEntry) error {
|
||||
childPath := p.Child(name)
|
||||
|
||||
existingEntry, findErr := f.Store.FindEntry(persistCtx, childPath)
|
||||
if findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
glog.Warningf("maybeLazyListFromRemote: find %s: %v", childPath, findErr)
|
||||
return nil // skip this entry on transient store error
|
||||
}
|
||||
|
||||
// Skip entries that exist locally without a RemoteEntry (local-only uploads)
|
||||
if existingEntry != nil && existingEntry.Remote == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if existingEntry != nil {
|
||||
// Merge: update remote metadata while preserving local state
|
||||
// (Chunks, Extended, Uid/Gid/Mode, etc.)
|
||||
existingEntry.Remote = remoteEntry
|
||||
if !isDirectory && remoteEntry != nil {
|
||||
if remoteEntry.RemoteMtime > 0 {
|
||||
existingEntry.Attr.Mtime = time.Unix(remoteEntry.RemoteMtime, 0)
|
||||
}
|
||||
existingEntry.Attr.FileSize = uint64(remoteEntry.RemoteSize)
|
||||
}
|
||||
if saveErr := f.Store.UpdateEntry(persistCtx, existingEntry); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: update %s: %v", childPath, saveErr)
|
||||
}
|
||||
} else {
|
||||
// New entry not yet in local store
|
||||
var entry *Entry
|
||||
if isDirectory {
|
||||
now := time.Now()
|
||||
entry = &Entry{
|
||||
FullPath: childPath,
|
||||
Attr: Attr{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
Mode: os.ModeDir | 0755,
|
||||
Uid: OS_UID,
|
||||
Gid: OS_GID,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mtime := time.Now()
|
||||
if remoteEntry != nil && remoteEntry.RemoteMtime > 0 {
|
||||
mtime = time.Unix(remoteEntry.RemoteMtime, 0)
|
||||
}
|
||||
entry = &Entry{
|
||||
FullPath: childPath,
|
||||
Attr: Attr{
|
||||
Mtime: mtime,
|
||||
Crtime: mtime,
|
||||
Mode: 0644,
|
||||
Uid: OS_UID,
|
||||
Gid: OS_GID,
|
||||
},
|
||||
Remote: remoteEntry,
|
||||
}
|
||||
if remoteEntry != nil {
|
||||
entry.Attr.FileSize = uint64(remoteEntry.RemoteSize)
|
||||
}
|
||||
}
|
||||
if saveErr := f.CreateEntry(persistCtx, entry, false, false, nil, true, f.MaxFilenameLength); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: persist %s: %v", childPath, saveErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if listErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: list %s: %v", p, listErr)
|
||||
return nil, nil // swallow error
|
||||
}
|
||||
|
||||
// Update the synced_at timestamp on the directory entry
|
||||
f.updateDirectoryListingSyncedAt(persistCtx, p, startTime)
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Filer) updateDirectoryListingSyncedAt(ctx context.Context, p util.FullPath, syncTime time.Time) {
|
||||
dirEntry, findErr := f.Store.FindEntry(ctx, p)
|
||||
if findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
glog.Warningf("maybeLazyListFromRemote: find dir %s: %v", p, findErr)
|
||||
return
|
||||
}
|
||||
if errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
// Directory doesn't exist yet, create it
|
||||
now := time.Now()
|
||||
dirEntry = &Entry{
|
||||
FullPath: p,
|
||||
Attr: Attr{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
Mode: os.ModeDir | 0755,
|
||||
Uid: OS_UID,
|
||||
Gid: OS_GID,
|
||||
},
|
||||
}
|
||||
if dirEntry.Extended == nil {
|
||||
dirEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
dirEntry.Extended[xattrRemoteListingSyncedAt] = []byte(fmt.Sprintf("%d", syncTime.Unix()))
|
||||
if saveErr := f.CreateEntry(ctx, dirEntry, false, false, nil, true, f.MaxFilenameLength); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: create dir synced_at for %s: %v", p, saveErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if dirEntry.Extended == nil {
|
||||
dirEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
dirEntry.Extended[xattrRemoteListingSyncedAt] = []byte(fmt.Sprintf("%d", syncTime.Unix()))
|
||||
if saveErr := f.Store.UpdateEntry(ctx, dirEntry); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: update synced_at for %s: %v", p, saveErr)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -26,34 +29,128 @@ import (
|
||||
// --- minimal FilerStore stub ---
|
||||
|
||||
type stubFilerStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*Entry
|
||||
insertErr error
|
||||
mu sync.Mutex
|
||||
entries map[string]*Entry
|
||||
kv map[string][]byte
|
||||
insertErr error
|
||||
deleteErrByPath map[string]error
|
||||
}
|
||||
|
||||
func newStubFilerStore() *stubFilerStore {
|
||||
return &stubFilerStore{entries: make(map[string]*Entry)}
|
||||
return &stubFilerStore{
|
||||
entries: make(map[string]*Entry),
|
||||
kv: make(map[string][]byte),
|
||||
deleteErrByPath: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) GetName() string { return "stub" }
|
||||
func (s *stubFilerStore) GetName() string { return "stub" }
|
||||
func (s *stubFilerStore) Initialize(util.Configuration, string) error { return nil }
|
||||
func (s *stubFilerStore) Shutdown() {}
|
||||
func (s *stubFilerStore) Shutdown() {}
|
||||
func (s *stubFilerStore) BeginTransaction(ctx context.Context) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
func (s *stubFilerStore) CommitTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) RollbackTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) KvPut(context.Context, []byte, []byte) error { return nil }
|
||||
func (s *stubFilerStore) KvGet(context.Context, []byte) ([]byte, error) {
|
||||
return nil, ErrKvNotFound
|
||||
func (s *stubFilerStore) CommitTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) RollbackTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) KvPut(_ context.Context, key []byte, value []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.kv[string(key)] = append([]byte(nil), value...)
|
||||
return nil
|
||||
}
|
||||
func (s *stubFilerStore) KvDelete(context.Context, []byte) error { return nil }
|
||||
func (s *stubFilerStore) DeleteFolderChildren(context.Context, util.FullPath) error { return nil }
|
||||
func (s *stubFilerStore) ListDirectoryEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
return "", nil
|
||||
func (s *stubFilerStore) KvGet(_ context.Context, key []byte) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
value, found := s.kv[string(key)]
|
||||
if !found {
|
||||
return nil, ErrKvNotFound
|
||||
}
|
||||
return append([]byte(nil), value...), nil
|
||||
}
|
||||
func (s *stubFilerStore) ListDirectoryPrefixedEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
return "", nil
|
||||
func (s *stubFilerStore) KvDelete(_ context.Context, key []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.kv, string(key))
|
||||
return nil
|
||||
}
|
||||
func (s *stubFilerStore) DeleteFolderChildren(_ context.Context, dirPath util.FullPath) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prefix := string(dirPath) + "/"
|
||||
for k := range s.entries {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(s.entries, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *stubFilerStore) listDirectoryChildNames(dirPath util.FullPath, startFileName string, includeStartFile bool, namePrefix string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dirPrefix := string(dirPath) + "/"
|
||||
var names []string
|
||||
for k := range s.entries {
|
||||
if !strings.HasPrefix(k, dirPrefix) {
|
||||
continue
|
||||
}
|
||||
rest := k[len(dirPrefix):]
|
||||
if strings.Contains(rest, "/") {
|
||||
continue
|
||||
}
|
||||
if namePrefix != "" && !strings.HasPrefix(rest, namePrefix) {
|
||||
continue
|
||||
}
|
||||
if rest > startFileName || (includeStartFile && rest == startFileName) {
|
||||
names = append(names, rest)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
func (s *stubFilerStore) getEntry(path string) *Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.entries[path]
|
||||
}
|
||||
func (s *stubFilerStore) ListDirectoryEntries(_ context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
names := s.listDirectoryChildNames(dirPath, startFileName, includeStartFile, "")
|
||||
dirPrefix := string(dirPath) + "/"
|
||||
lastFileName := ""
|
||||
for i, name := range names {
|
||||
if int64(i) >= limit {
|
||||
break
|
||||
}
|
||||
entry := s.getEntry(dirPrefix + name)
|
||||
cont, err := eachEntryFunc(entry)
|
||||
if err != nil {
|
||||
return lastFileName, err
|
||||
}
|
||||
lastFileName = name
|
||||
if !cont {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastFileName, nil
|
||||
}
|
||||
func (s *stubFilerStore) ListDirectoryPrefixedEntries(_ context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
names := s.listDirectoryChildNames(dirPath, startFileName, includeStartFile, prefix)
|
||||
dirPrefix := string(dirPath) + "/"
|
||||
lastFileName := ""
|
||||
for i, name := range names {
|
||||
if int64(i) >= limit {
|
||||
break
|
||||
}
|
||||
entry := s.getEntry(dirPrefix + name)
|
||||
cont, err := eachEntryFunc(entry)
|
||||
if err != nil {
|
||||
return lastFileName, err
|
||||
}
|
||||
lastFileName = name
|
||||
if !cont {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastFileName, nil
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) InsertEntry(_ context.Context, entry *Entry) error {
|
||||
@@ -85,6 +182,9 @@ func (s *stubFilerStore) FindEntry(_ context.Context, p util.FullPath) (*Entry,
|
||||
func (s *stubFilerStore) DeleteEntry(_ context.Context, p util.FullPath) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if deleteErr, found := s.deleteErrByPath[string(p)]; found && deleteErr != nil {
|
||||
return deleteErr
|
||||
}
|
||||
delete(s.entries, string(p))
|
||||
return nil
|
||||
}
|
||||
@@ -94,6 +194,14 @@ func (s *stubFilerStore) DeleteEntry(_ context.Context, p util.FullPath) error {
|
||||
type stubRemoteClient struct {
|
||||
statResult *filer_pb.RemoteEntry
|
||||
statErr error
|
||||
deleteErr error
|
||||
removeErr error
|
||||
|
||||
deleteCalls []*remote_pb.RemoteStorageLocation
|
||||
removeCalls []*remote_pb.RemoteStorageLocation
|
||||
|
||||
listDirFn func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error
|
||||
listDirCalls int
|
||||
}
|
||||
|
||||
func (c *stubRemoteClient) StatFile(*remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
|
||||
@@ -108,17 +216,38 @@ func (c *stubRemoteClient) ReadFile(*remote_pb.RemoteStorageLocation, int64, int
|
||||
func (c *stubRemoteClient) WriteDirectory(*remote_pb.RemoteStorageLocation, *filer_pb.Entry) error {
|
||||
return nil
|
||||
}
|
||||
func (c *stubRemoteClient) RemoveDirectory(*remote_pb.RemoteStorageLocation) error { return nil }
|
||||
func (c *stubRemoteClient) RemoveDirectory(loc *remote_pb.RemoteStorageLocation) error {
|
||||
c.removeCalls = append(c.removeCalls, &remote_pb.RemoteStorageLocation{
|
||||
Name: loc.Name,
|
||||
Bucket: loc.Bucket,
|
||||
Path: loc.Path,
|
||||
})
|
||||
return c.removeErr
|
||||
}
|
||||
func (c *stubRemoteClient) WriteFile(*remote_pb.RemoteStorageLocation, *filer_pb.Entry, io.Reader) (*filer_pb.RemoteEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *stubRemoteClient) UpdateFileMetadata(*remote_pb.RemoteStorageLocation, *filer_pb.Entry, *filer_pb.Entry) error {
|
||||
return nil
|
||||
}
|
||||
func (c *stubRemoteClient) DeleteFile(*remote_pb.RemoteStorageLocation) error { return nil }
|
||||
func (c *stubRemoteClient) ListBuckets() ([]*remote_storage.Bucket, error) { return nil, nil }
|
||||
func (c *stubRemoteClient) CreateBucket(string) error { return nil }
|
||||
func (c *stubRemoteClient) DeleteBucket(string) error { return nil }
|
||||
func (c *stubRemoteClient) DeleteFile(loc *remote_pb.RemoteStorageLocation) error {
|
||||
c.deleteCalls = append(c.deleteCalls, &remote_pb.RemoteStorageLocation{
|
||||
Name: loc.Name,
|
||||
Bucket: loc.Bucket,
|
||||
Path: loc.Path,
|
||||
})
|
||||
return c.deleteErr
|
||||
}
|
||||
func (c *stubRemoteClient) ListDirectory(_ context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
c.listDirCalls++
|
||||
if c.listDirFn != nil {
|
||||
return c.listDirFn(loc, visitFn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *stubRemoteClient) ListBuckets() ([]*remote_storage.Bucket, error) { return nil, nil }
|
||||
func (c *stubRemoteClient) CreateBucket(string) error { return nil }
|
||||
func (c *stubRemoteClient) DeleteBucket(string) error { return nil }
|
||||
|
||||
// --- stub RemoteStorageClientMaker ---
|
||||
|
||||
@@ -144,9 +273,11 @@ func newTestFiler(t *testing.T, store *stubFilerStore, rs *FilerRemoteStorage) *
|
||||
f := &Filer{
|
||||
RemoteStorage: rs,
|
||||
Store: NewFilerStoreWrapper(store),
|
||||
FilerConf: NewFilerConf(),
|
||||
MaxFilenameLength: 255,
|
||||
MasterClient: mc,
|
||||
fileIdDeletionQueue: util.NewUnboundedQueue(),
|
||||
deletionQuit: make(chan struct{}),
|
||||
LocalMetaLogBuffer: log_buffer.NewLogBuffer("test", time.Minute,
|
||||
func(*log_buffer.LogBuffer, time.Time, time.Time, []byte, int64, int64) {}, nil, func() {}),
|
||||
}
|
||||
@@ -368,3 +499,603 @@ func TestFindEntry_LazyFetchOnMiss(t *testing.T) {
|
||||
require.NotNil(t, entry2)
|
||||
assert.Equal(t, uint64(999), entry2.FileSize)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteOnlyFileDeletesRemoteAndMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_file"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "deletestore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "deletestore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/file.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 64,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 64},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
require.Len(t, stub.deleteCalls, 1)
|
||||
assert.Equal(t, "deletestore", stub.deleteCalls[0].Name)
|
||||
assert.Equal(t, "mybucket", stub.deleteCalls[0].Bucket)
|
||||
assert.Equal(t, "/file.txt", stub.deleteCalls[0].Path)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_IsFromOtherClusterSkipsRemoteDelete(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_other_cluster"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "othercluster", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "othercluster",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/replicated.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 64,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 64},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
// isFromOtherCluster=true simulates a replicated delete from another filer
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, true, nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Local metadata should be deleted
|
||||
_, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
// Remote should NOT have been called — the originating filer handles that
|
||||
require.Len(t, stub.deleteCalls, 0)
|
||||
require.Len(t, stub.removeCalls, 0)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteOnlyFileNotUnderMountSkipsRemoteDelete(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_not_under_mount"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "notundermount", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "notundermount",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/no/mount/file.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 99,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 99},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stub.deleteCalls, 0)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteMountWithoutClientResolutionKeepsMetadata(t *testing.T) {
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf["missingclient"] = &remote_pb.RemoteConf{Name: "missingclient", Type: "stub_missing_client"}
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "missingclient",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/no-client.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 51,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 51},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "resolve remote storage client")
|
||||
require.ErrorContains(t, err, string(filePath))
|
||||
|
||||
stored, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, stored)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_LocalDeleteFailurePreservesMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_local_fail"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "localfail", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "localfail",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/localfail.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 80,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 80},
|
||||
}
|
||||
store.deleteErrByPath[string(filePath)] = errors.New("simulated local delete failure")
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "filer store delete")
|
||||
require.Len(t, stub.deleteCalls, 1)
|
||||
|
||||
// Local metadata should still exist since local delete failed
|
||||
stored, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, stored)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteDeleteNotFoundStillDeletesMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_notfound"
|
||||
stub := &stubRemoteClient{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "deletenotfound", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "deletenotfound",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/notfound.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 23,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 23},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
_, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteDeleteErrorKeepsMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_error"
|
||||
stub := &stubRemoteClient{deleteErr: errors.New("remote delete failed")}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "deleteerr", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "deleteerr",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/error.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 77,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 77},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.Error(t, err)
|
||||
stored, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, stored)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_DirectoryUnderMountDeletesRemoteDirectory(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_dir"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "dirstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "dirstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
dirPath := util.FullPath("/buckets/mybucket/dir")
|
||||
store.entries[string(dirPath)] = &Entry{
|
||||
FullPath: dirPath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: os.ModeDir | 0755,
|
||||
},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.doDeleteEntryMetaAndData(context.Background(), store.entries[string(dirPath)], false, false, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, stub.removeCalls, 1)
|
||||
assert.Equal(t, "dirstore", stub.removeCalls[0].Name)
|
||||
assert.Equal(t, "mybucket", stub.removeCalls[0].Bucket)
|
||||
assert.Equal(t, "/dir", stub.removeCalls[0].Path)
|
||||
_, findErr := store.FindEntry(context.Background(), dirPath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RecursiveFolderDeleteRemotesChildren(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_folder_children"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "childstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "childstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
dirPath := util.FullPath("/buckets/mybucket/subdir")
|
||||
store.entries[string(dirPath)] = &Entry{
|
||||
FullPath: dirPath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: os.ModeDir | 0755,
|
||||
},
|
||||
}
|
||||
childPath := util.FullPath("/buckets/mybucket/subdir/child.txt")
|
||||
store.entries[string(childPath)] = &Entry{
|
||||
FullPath: childPath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 50,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 50},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), dirPath, true, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Child file should have been deleted from remote
|
||||
require.Len(t, stub.deleteCalls, 1)
|
||||
assert.Equal(t, "/subdir/child.txt", stub.deleteCalls[0].Path)
|
||||
// Directory itself should also have been deleted from remote
|
||||
require.Len(t, stub.removeCalls, 1)
|
||||
assert.Equal(t, "/subdir", stub.removeCalls[0].Path)
|
||||
}
|
||||
|
||||
// --- lazy listing tests ---
|
||||
|
||||
func TestMaybeLazyListFromRemote_PopulatesStoreFromRemote(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_populate"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
if err := visitFn("/", "subdir", true, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visitFn("/", "file.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000,
|
||||
RemoteSize: 42,
|
||||
RemoteETag: "abc",
|
||||
StorageName: "myliststore",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "myliststore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "myliststore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls)
|
||||
|
||||
// Check that the file was persisted
|
||||
fileEntry := store.getEntry("/buckets/mybucket/file.txt")
|
||||
require.NotNil(t, fileEntry, "file.txt should be persisted")
|
||||
assert.Equal(t, uint64(42), fileEntry.FileSize)
|
||||
assert.NotNil(t, fileEntry.Remote)
|
||||
|
||||
// Check that the subdirectory was persisted
|
||||
dirEntry := store.getEntry("/buckets/mybucket/subdir")
|
||||
require.NotNil(t, dirEntry, "subdir should be persisted")
|
||||
assert.True(t, dirEntry.IsDirectory())
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_DisabledWhenTTLZero(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_disabled"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "file.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000, RemoteSize: 10,
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "disabledstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "disabledstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
// ListingCacheTtlSeconds defaults to 0 → disabled
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 0, stub.listDirCalls, "should not call remote when TTL is 0")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_TTLCachePreventsSecondCall(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_ttl"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "file.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000, RemoteSize: 10,
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "ttlstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "ttlstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
// First call should hit remote
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls)
|
||||
|
||||
// Second call within TTL should be a no-op
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls, "should not call remote again within TTL")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_NotUnderMount(t *testing.T) {
|
||||
rs := NewFilerRemoteStorage()
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/not/a/mount"))
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_SkipsLocalOnlyEntries(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_skiplocal"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
// Remote has a file called "local.txt" too
|
||||
return visitFn("/", "local.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000, RemoteSize: 99,
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "skipstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "skipstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
// Pre-populate a local-only entry (no Remote field)
|
||||
store.entries["/buckets/mybucket/local.txt"] = &Entry{
|
||||
FullPath: "/buckets/mybucket/local.txt",
|
||||
Attr: Attr{Mode: 0644, FileSize: 50},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
|
||||
// Local entry should NOT have been overwritten
|
||||
localEntry := store.getEntry("/buckets/mybucket/local.txt")
|
||||
require.NotNil(t, localEntry)
|
||||
assert.Equal(t, uint64(50), localEntry.FileSize, "local-only entry should not be overwritten")
|
||||
assert.Nil(t, localEntry.Remote, "local-only entry should keep nil Remote")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_MergesExistingRemoteEntry(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_merge"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "cached.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000099, // updated mtime
|
||||
RemoteSize: 200, // updated size
|
||||
RemoteETag: "new-etag",
|
||||
StorageName: "mergestore",
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "mergestore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "mergestore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
// Pre-populate an existing remote-backed entry with chunks and extended attrs
|
||||
existingChunks := []*filer_pb.FileChunk{
|
||||
{FileId: "1,abc123", Size: 100, Offset: 0},
|
||||
}
|
||||
store.entries["/buckets/mybucket/cached.txt"] = &Entry{
|
||||
FullPath: "/buckets/mybucket/cached.txt",
|
||||
Attr: Attr{
|
||||
Mode: 0644,
|
||||
FileSize: 100,
|
||||
Uid: 1000,
|
||||
Gid: 1000,
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1699000000, 0),
|
||||
},
|
||||
Chunks: existingChunks,
|
||||
Extended: map[string][]byte{
|
||||
"user.custom": []byte("myvalue"),
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000,
|
||||
RemoteSize: 100,
|
||||
RemoteETag: "old-etag",
|
||||
StorageName: "mergestore",
|
||||
},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls)
|
||||
|
||||
merged := store.getEntry("/buckets/mybucket/cached.txt")
|
||||
require.NotNil(t, merged)
|
||||
|
||||
// Remote metadata should be updated
|
||||
assert.Equal(t, int64(1700000099), merged.Remote.RemoteMtime)
|
||||
assert.Equal(t, int64(200), merged.Remote.RemoteSize)
|
||||
assert.Equal(t, "new-etag", merged.Remote.RemoteETag)
|
||||
assert.Equal(t, uint64(200), merged.FileSize)
|
||||
assert.Equal(t, time.Unix(1700000099, 0), merged.Mtime)
|
||||
|
||||
// Local state should be preserved
|
||||
assert.Equal(t, existingChunks, merged.Chunks, "chunks must be preserved")
|
||||
assert.Equal(t, []byte("myvalue"), merged.Extended["user.custom"], "extended attrs must be preserved")
|
||||
assert.Equal(t, uint32(1000), merged.Uid, "uid must be preserved")
|
||||
assert.Equal(t, uint32(1000), merged.Gid, "gid must be preserved")
|
||||
assert.Equal(t, os.FileMode(0644), merged.Mode, "mode must be preserved")
|
||||
assert.Equal(t, time.Unix(1699000000, 0), merged.Crtime, "crtime must be preserved")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_ContextGuardPreventsRecursion(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_guard"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "guardliststore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "guardliststore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
// With lazyListContextKey set, should be a no-op
|
||||
guardCtx := context.WithValue(context.Background(), lazyListContextKey{}, true)
|
||||
f.maybeLazyListFromRemote(guardCtx, util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 0, stub.listDirCalls)
|
||||
|
||||
// With lazyFetchContextKey set, should also be a no-op
|
||||
fetchCtx := context.WithValue(context.Background(), lazyFetchContextKey{}, true)
|
||||
f.maybeLazyListFromRemote(fetchCtx, util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 0, stub.listDirCalls)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ func TestFilerRemoteStorage_FindMountDirectory_LongestPrefixWins(t *testing.T) {
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantMount string
|
||||
wantBucket string
|
||||
path string
|
||||
wantMount string
|
||||
wantBucket string
|
||||
}{
|
||||
{"/buckets/mybucket/file.txt", "/buckets/mybucket", "bucket-root"},
|
||||
{"/buckets/mybucket/prefix/file.txt", "/buckets/mybucket/prefix", "bucket-prefix"},
|
||||
|
||||
@@ -27,11 +27,18 @@ func (m *IAMManager) ValidateTrustPolicyForPrincipal(ctx context.Context, roleAr
|
||||
return fmt.Errorf("role has no trust policy")
|
||||
}
|
||||
|
||||
// Create evaluation context
|
||||
// Create evaluation context with RequestContext populated so that
|
||||
// principal matching works for specific (non-wildcard) principals.
|
||||
// Without this, evaluatePrincipalValue cannot look up "aws:PrincipalArn"
|
||||
// and always returns false for non-wildcard trust policy principals.
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: principalArn,
|
||||
Action: "sts:AssumeRole",
|
||||
Resource: roleArn,
|
||||
RequestContext: map[string]interface{}{
|
||||
"principal": principalArn,
|
||||
"aws:PrincipalArn": principalArn,
|
||||
},
|
||||
}
|
||||
|
||||
// Evaluate the trust policy
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTrustPolicyAWSUserPrincipal tests that trust policies with specific AWS user
|
||||
// principals work correctly with ValidateTrustPolicyForPrincipal.
|
||||
// This is a regression test for the case where setting a specific user principal like:
|
||||
//
|
||||
// "Principal": {"AWS": "arn:aws:iam::000000000000:user/backend"}
|
||||
//
|
||||
// would fail with "trust policy denies access to principal" even though the
|
||||
// caller's principal ARN matched.
|
||||
func TestTrustPolicyAWSUserPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
backendUser = "backend"
|
||||
backendArn = "arn:aws:iam::" + accountID + ":user/" + backendUser
|
||||
otherUser = "other"
|
||||
otherArn = "arn:aws:iam::" + accountID + ":user/" + otherUser
|
||||
clientRoleN = "ClientRole"
|
||||
clientRoleA = "arn:aws:iam::role/" + clientRoleN
|
||||
)
|
||||
|
||||
// Create role with trust policy restricted to a specific AWS user principal
|
||||
// (the exact scenario from discussion #8588)
|
||||
err := iamManager.CreateRole(ctx, "", clientRoleN, &RoleDefinition{
|
||||
RoleName: clientRoleN,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": backendArn,
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principalArn string
|
||||
expectErr bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "matching user principal should be allowed",
|
||||
principalArn: backendArn,
|
||||
expectErr: false,
|
||||
description: "Backend user ARN matches the trust policy principal exactly",
|
||||
},
|
||||
{
|
||||
name: "non-matching user principal should be denied",
|
||||
principalArn: otherArn,
|
||||
expectErr: true,
|
||||
description: "Other user ARN does not match the trust policy principal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := iamManager.ValidateTrustPolicyForPrincipal(ctx, clientRoleA, tt.principalArn)
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err, tt.description)
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
} else {
|
||||
assert.NoError(t, err, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyAWSWildcardPrincipal tests that wildcard AWS principals
|
||||
// continue to work correctly (this already works, serves as a regression guard).
|
||||
func TestTrustPolicyAWSWildcardPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const roleName = "WildcardAWSRole"
|
||||
const roleArn = "arn:aws:iam::role/" + roleName
|
||||
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Any user should be able to assume a role with wildcard AWS principal
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, "arn:aws:iam::000000000000:user/anyuser")
|
||||
assert.NoError(t, err, "Wildcard AWS principal should allow any user")
|
||||
}
|
||||
|
||||
// TestTrustPolicyAWSUserArrayPrincipal tests trust policies with an array of
|
||||
// AWS user principals.
|
||||
func TestTrustPolicyAWSUserArrayPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
roleName = "MultiUserRole"
|
||||
roleArn = "arn:aws:iam::role/" + roleName
|
||||
user1Arn = "arn:aws:iam::" + accountID + ":user/user1"
|
||||
user2Arn = "arn:aws:iam::" + accountID + ":user/user2"
|
||||
user3Arn = "arn:aws:iam::" + accountID + ":user/user3"
|
||||
)
|
||||
|
||||
// Create role with multiple allowed user principals
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": []interface{}{user1Arn, user2Arn},
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principalArn string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "first listed user is allowed",
|
||||
principalArn: user1Arn,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "second listed user is allowed",
|
||||
principalArn: user2Arn,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "unlisted user is denied",
|
||||
principalArn: user3Arn,
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, tt.principalArn)
|
||||
if tt.expectErr {
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyPlainStringPrincipal tests trust policies with a plain string
|
||||
// principal (not wrapped in {"AWS": ...}).
|
||||
func TestTrustPolicyPlainStringPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
roleName = "PlainStringPrincipalRole"
|
||||
roleArn = "arn:aws:iam::role/" + roleName
|
||||
userArn = "arn:aws:iam::000000000000:user/backend"
|
||||
)
|
||||
|
||||
// Plain string wildcard - should work
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: "*",
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, userArn)
|
||||
assert.NoError(t, err, "Plain wildcard principal should allow any user")
|
||||
|
||||
// Plain string with specific ARN
|
||||
const roleName2 = "PlainStringSpecificRole"
|
||||
const roleArn2 = "arn:aws:iam::role/" + roleName2
|
||||
|
||||
err = iamManager.CreateRole(ctx, "", roleName2, &RoleDefinition{
|
||||
RoleName: roleName2,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: userArn,
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn2, userArn)
|
||||
assert.NoError(t, err, "Matching plain string principal should allow user")
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn2, "arn:aws:iam::000000000000:user/other")
|
||||
if assert.Error(t, err, "Non-matching plain string principal should deny user") {
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@ type ListAttachedGroupPoliciesResponse struct {
|
||||
|
||||
// ListGroupsForUserResponse is the response for ListGroupsForUser action.
|
||||
type ListGroupsForUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"`
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"`
|
||||
ListGroupsForUserResult struct {
|
||||
Groups []*iam.Group `xml:"Groups>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
|
||||
@@ -326,4 +326,3 @@ func buildUserGroupsIndex(s3cfg *iam_pb.S3ApiConfiguration) map[string][]string
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
|
||||
@@ -9,19 +9,19 @@ import (
|
||||
|
||||
// Type aliases for IAM response types from shared package
|
||||
type (
|
||||
CommonResponse = iamlib.CommonResponse
|
||||
ListUsersResponse = iamlib.ListUsersResponse
|
||||
ListAccessKeysResponse = iamlib.ListAccessKeysResponse
|
||||
DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse
|
||||
CreatePolicyResponse = iamlib.CreatePolicyResponse
|
||||
CreateUserResponse = iamlib.CreateUserResponse
|
||||
DeleteUserResponse = iamlib.DeleteUserResponse
|
||||
GetUserResponse = iamlib.GetUserResponse
|
||||
UpdateUserResponse = iamlib.UpdateUserResponse
|
||||
CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse
|
||||
UpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse
|
||||
PutUserPolicyResponse = iamlib.PutUserPolicyResponse
|
||||
DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse
|
||||
CommonResponse = iamlib.CommonResponse
|
||||
ListUsersResponse = iamlib.ListUsersResponse
|
||||
ListAccessKeysResponse = iamlib.ListAccessKeysResponse
|
||||
DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse
|
||||
CreatePolicyResponse = iamlib.CreatePolicyResponse
|
||||
CreateUserResponse = iamlib.CreateUserResponse
|
||||
DeleteUserResponse = iamlib.DeleteUserResponse
|
||||
GetUserResponse = iamlib.GetUserResponse
|
||||
UpdateUserResponse = iamlib.UpdateUserResponse
|
||||
CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse
|
||||
UpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse
|
||||
PutUserPolicyResponse = iamlib.PutUserPolicyResponse
|
||||
DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse
|
||||
GetUserPolicyResponse = iamlib.GetUserPolicyResponse
|
||||
GetPolicyResponse = iamlib.GetPolicyResponse
|
||||
DeletePolicyResponse = iamlib.DeletePolicyResponse
|
||||
@@ -30,12 +30,12 @@ type (
|
||||
DetachUserPolicyResponse = iamlib.DetachUserPolicyResponse
|
||||
ListAttachedUserPoliciesResponse = iamlib.ListAttachedUserPoliciesResponse
|
||||
ErrorResponse = iamlib.ErrorResponse
|
||||
ServiceAccountInfo = iamlib.ServiceAccountInfo
|
||||
CreateServiceAccountResponse = iamlib.CreateServiceAccountResponse
|
||||
DeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse
|
||||
ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse
|
||||
GetServiceAccountResponse = iamlib.GetServiceAccountResponse
|
||||
UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse
|
||||
ServiceAccountInfo = iamlib.ServiceAccountInfo
|
||||
CreateServiceAccountResponse = iamlib.CreateServiceAccountResponse
|
||||
DeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse
|
||||
ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse
|
||||
GetServiceAccountResponse = iamlib.GetServiceAccountResponse
|
||||
UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse
|
||||
// Group response types
|
||||
CreateGroupResponse = iamlib.CreateGroupResponse
|
||||
DeleteGroupResponse = iamlib.DeleteGroupResponse
|
||||
|
||||
@@ -37,8 +37,8 @@ type MetaCache struct {
|
||||
applyDone chan struct{}
|
||||
applyStateMu sync.Mutex
|
||||
applyClosed bool
|
||||
buildingDirs map[util.FullPath]*directoryBuildState
|
||||
dedupRing dedupRingBuffer
|
||||
buildingDirs map[util.FullPath]*directoryBuildState
|
||||
dedupRing dedupRingBuffer
|
||||
}
|
||||
|
||||
var errMetaCacheClosed = errors.New("metadata cache is shut down")
|
||||
@@ -98,8 +98,8 @@ func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPat
|
||||
invalidateFunc: func(fullpath util.FullPath, entry *filer_pb.Entry) {
|
||||
invalidateFunc(fullpath, entry)
|
||||
},
|
||||
applyCh: make(chan metadataApplyRequest, 128),
|
||||
applyDone: make(chan struct{}),
|
||||
applyCh: make(chan metadataApplyRequest, 128),
|
||||
applyDone: make(chan struct{}),
|
||||
buildingDirs: make(map[util.FullPath]*directoryBuildState),
|
||||
dedupRing: newDedupRingBuffer(),
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ func (c *RDMAMountClient) ReadNeedle(ctx context.Context, fileID string, offset,
|
||||
|
||||
var data []byte
|
||||
|
||||
var n int
|
||||
if useTempFile && tempFilePath != "" {
|
||||
// Zero-copy path: read from temp file (page cache)
|
||||
glog.V(4).Infof("🔥 Using zero-copy temp file: %s", tempFilePath)
|
||||
@@ -237,7 +238,7 @@ func (c *RDMAMountClient) ReadNeedle(ctx context.Context, fileID string, offset,
|
||||
}
|
||||
buffer := make([]byte, bufferSize)
|
||||
|
||||
n, err := c.readFromTempFile(tempFilePath, buffer)
|
||||
n, err = c.readFromTempFile(tempFilePath, buffer)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Zero-copy failed, falling back to HTTP body: %v", err)
|
||||
// Fall back to reading HTTP body
|
||||
|
||||
@@ -213,6 +213,7 @@ message UpdateEntryRequest {
|
||||
Entry entry = 2;
|
||||
bool is_from_other_cluster = 3;
|
||||
repeated int32 signatures = 4;
|
||||
map<string, bytes> expected_extended = 5;
|
||||
}
|
||||
message UpdateEntryResponse {
|
||||
SubscribeMetadataResponse metadata_event = 1;
|
||||
|
||||
@@ -1175,6 +1175,7 @@ type UpdateEntryRequest struct {
|
||||
Entry *Entry `protobuf:"bytes,2,opt,name=entry,proto3" json:"entry,omitempty"`
|
||||
IsFromOtherCluster bool `protobuf:"varint,3,opt,name=is_from_other_cluster,json=isFromOtherCluster,proto3" json:"is_from_other_cluster,omitempty"`
|
||||
Signatures []int32 `protobuf:"varint,4,rep,packed,name=signatures,proto3" json:"signatures,omitempty"`
|
||||
ExpectedExtended map[string][]byte `protobuf:"bytes,5,rep,name=expected_extended,json=expectedExtended,proto3" json:"expected_extended,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1237,6 +1238,13 @@ func (x *UpdateEntryRequest) GetSignatures() []int32 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *UpdateEntryRequest) GetExpectedExtended() map[string][]byte {
|
||||
if x != nil {
|
||||
return x.ExpectedExtended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateEntryResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MetadataEvent *SubscribeMetadataResponse `protobuf:"bytes,1,opt,name=metadata_event,json=metadataEvent,proto3" json:"metadata_event,omitempty"`
|
||||
@@ -4234,7 +4242,7 @@ type LocateBrokerResponse_Resource struct {
|
||||
|
||||
func (x *LocateBrokerResponse_Resource) Reset() {
|
||||
*x = LocateBrokerResponse_Resource{}
|
||||
mi := &file_filer_proto_msgTypes[68]
|
||||
mi := &file_filer_proto_msgTypes[69]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -4246,7 +4254,7 @@ func (x *LocateBrokerResponse_Resource) String() string {
|
||||
func (*LocateBrokerResponse_Resource) ProtoMessage() {}
|
||||
|
||||
func (x *LocateBrokerResponse_Resource) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_filer_proto_msgTypes[68]
|
||||
mi := &file_filer_proto_msgTypes[69]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -4300,7 +4308,7 @@ type FilerConf_PathConf struct {
|
||||
|
||||
func (x *FilerConf_PathConf) Reset() {
|
||||
*x = FilerConf_PathConf{}
|
||||
mi := &file_filer_proto_msgTypes[69]
|
||||
mi := &file_filer_proto_msgTypes[70]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -4312,7 +4320,7 @@ func (x *FilerConf_PathConf) String() string {
|
||||
func (*FilerConf_PathConf) ProtoMessage() {}
|
||||
|
||||
func (x *FilerConf_PathConf) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_filer_proto_msgTypes[69]
|
||||
mi := &file_filer_proto_msgTypes[70]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -4550,14 +4558,18 @@ const file_filer_proto_rawDesc = "" +
|
||||
"\x1bskip_check_parent_directory\x18\x06 \x01(\bR\x18skipCheckParentDirectory\"w\n" +
|
||||
"\x13CreateEntryResponse\x12\x14\n" +
|
||||
"\x05error\x18\x01 \x01(\tR\x05error\x12J\n" +
|
||||
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\xac\x01\n" +
|
||||
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\xd2\x02\n" +
|
||||
"\x12UpdateEntryRequest\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12%\n" +
|
||||
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x121\n" +
|
||||
"\x15is_from_other_cluster\x18\x03 \x01(\bR\x12isFromOtherCluster\x12\x1e\n" +
|
||||
"\n" +
|
||||
"signatures\x18\x04 \x03(\x05R\n" +
|
||||
"signatures\"a\n" +
|
||||
"signatures\x12_\n" +
|
||||
"\x11expected_extended\x18\x05 \x03(\v22.filer_pb.UpdateEntryRequest.ExpectedExtendedEntryR\x10expectedExtended\x1aC\n" +
|
||||
"\x15ExpectedExtendedEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"a\n" +
|
||||
"\x13UpdateEntryResponse\x12J\n" +
|
||||
"\x0emetadata_event\x18\x01 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\x80\x01\n" +
|
||||
"\x14AppendToEntryRequest\x12\x1c\n" +
|
||||
@@ -4865,7 +4877,7 @@ func file_filer_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_filer_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_filer_proto_msgTypes = make([]protoimpl.MessageInfo, 70)
|
||||
var file_filer_proto_msgTypes = make([]protoimpl.MessageInfo, 71)
|
||||
var file_filer_proto_goTypes = []any{
|
||||
(SSEType)(0), // 0: filer_pb.SSEType
|
||||
(*LookupDirectoryEntryRequest)(nil), // 1: filer_pb.LookupDirectoryEntryRequest
|
||||
@@ -4935,9 +4947,10 @@ var file_filer_proto_goTypes = []any{
|
||||
(*TransferLocksRequest)(nil), // 65: filer_pb.TransferLocksRequest
|
||||
(*TransferLocksResponse)(nil), // 66: filer_pb.TransferLocksResponse
|
||||
nil, // 67: filer_pb.Entry.ExtendedEntry
|
||||
nil, // 68: filer_pb.LookupVolumeResponse.LocationsMapEntry
|
||||
(*LocateBrokerResponse_Resource)(nil), // 69: filer_pb.LocateBrokerResponse.Resource
|
||||
(*FilerConf_PathConf)(nil), // 70: filer_pb.FilerConf.PathConf
|
||||
nil, // 68: filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
|
||||
nil, // 69: filer_pb.LookupVolumeResponse.LocationsMapEntry
|
||||
(*LocateBrokerResponse_Resource)(nil), // 70: filer_pb.LocateBrokerResponse.Resource
|
||||
(*FilerConf_PathConf)(nil), // 71: filer_pb.FilerConf.PathConf
|
||||
}
|
||||
var file_filer_proto_depIdxs = []int32{
|
||||
6, // 0: filer_pb.LookupDirectoryEntryResponse.entry:type_name -> filer_pb.Entry
|
||||
@@ -4956,77 +4969,78 @@ var file_filer_proto_depIdxs = []int32{
|
||||
6, // 13: filer_pb.CreateEntryRequest.entry:type_name -> filer_pb.Entry
|
||||
43, // 14: filer_pb.CreateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
6, // 15: filer_pb.UpdateEntryRequest.entry:type_name -> filer_pb.Entry
|
||||
43, // 16: filer_pb.UpdateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
9, // 17: filer_pb.AppendToEntryRequest.chunks:type_name -> filer_pb.FileChunk
|
||||
43, // 18: filer_pb.DeleteEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
8, // 19: filer_pb.StreamRenameEntryResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
29, // 20: filer_pb.AssignVolumeResponse.location:type_name -> filer_pb.Location
|
||||
29, // 21: filer_pb.Locations.locations:type_name -> filer_pb.Location
|
||||
68, // 22: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
|
||||
31, // 23: filer_pb.CollectionListResponse.collections:type_name -> filer_pb.Collection
|
||||
8, // 24: filer_pb.SubscribeMetadataResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
6, // 25: filer_pb.TraverseBfsMetadataResponse.entry:type_name -> filer_pb.Entry
|
||||
69, // 26: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
|
||||
70, // 27: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
|
||||
6, // 28: filer_pb.CacheRemoteObjectToLocalClusterResponse.entry:type_name -> filer_pb.Entry
|
||||
43, // 29: filer_pb.CacheRemoteObjectToLocalClusterResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
64, // 30: filer_pb.TransferLocksRequest.locks:type_name -> filer_pb.Lock
|
||||
28, // 31: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
|
||||
1, // 32: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
|
||||
3, // 33: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
|
||||
13, // 34: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
|
||||
15, // 35: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
|
||||
17, // 36: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
|
||||
19, // 37: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
|
||||
21, // 38: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
|
||||
23, // 39: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
|
||||
25, // 40: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
|
||||
27, // 41: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
|
||||
32, // 42: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
|
||||
34, // 43: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
|
||||
36, // 44: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
|
||||
38, // 45: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
|
||||
40, // 46: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
|
||||
44, // 47: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
|
||||
42, // 48: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
42, // 49: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
51, // 50: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
|
||||
53, // 51: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
|
||||
56, // 52: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
|
||||
58, // 53: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
|
||||
60, // 54: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
|
||||
62, // 55: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
|
||||
65, // 56: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
|
||||
2, // 57: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
|
||||
4, // 58: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
|
||||
14, // 59: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
|
||||
16, // 60: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
|
||||
18, // 61: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
|
||||
20, // 62: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
|
||||
22, // 63: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
|
||||
24, // 64: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
|
||||
26, // 65: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
|
||||
30, // 66: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
|
||||
33, // 67: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
|
||||
35, // 68: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
|
||||
37, // 69: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
|
||||
39, // 70: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
|
||||
41, // 71: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
|
||||
45, // 72: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
|
||||
43, // 73: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
43, // 74: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
52, // 75: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
|
||||
54, // 76: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
|
||||
57, // 77: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
|
||||
59, // 78: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
|
||||
61, // 79: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
|
||||
63, // 80: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
|
||||
66, // 81: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
|
||||
57, // [57:82] is the sub-list for method output_type
|
||||
32, // [32:57] is the sub-list for method input_type
|
||||
32, // [32:32] is the sub-list for extension type_name
|
||||
32, // [32:32] is the sub-list for extension extendee
|
||||
0, // [0:32] is the sub-list for field type_name
|
||||
68, // 16: filer_pb.UpdateEntryRequest.expected_extended:type_name -> filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
|
||||
43, // 17: filer_pb.UpdateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
9, // 18: filer_pb.AppendToEntryRequest.chunks:type_name -> filer_pb.FileChunk
|
||||
43, // 19: filer_pb.DeleteEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
8, // 20: filer_pb.StreamRenameEntryResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
29, // 21: filer_pb.AssignVolumeResponse.location:type_name -> filer_pb.Location
|
||||
29, // 22: filer_pb.Locations.locations:type_name -> filer_pb.Location
|
||||
69, // 23: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
|
||||
31, // 24: filer_pb.CollectionListResponse.collections:type_name -> filer_pb.Collection
|
||||
8, // 25: filer_pb.SubscribeMetadataResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
6, // 26: filer_pb.TraverseBfsMetadataResponse.entry:type_name -> filer_pb.Entry
|
||||
70, // 27: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
|
||||
71, // 28: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
|
||||
6, // 29: filer_pb.CacheRemoteObjectToLocalClusterResponse.entry:type_name -> filer_pb.Entry
|
||||
43, // 30: filer_pb.CacheRemoteObjectToLocalClusterResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
64, // 31: filer_pb.TransferLocksRequest.locks:type_name -> filer_pb.Lock
|
||||
28, // 32: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
|
||||
1, // 33: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
|
||||
3, // 34: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
|
||||
13, // 35: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
|
||||
15, // 36: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
|
||||
17, // 37: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
|
||||
19, // 38: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
|
||||
21, // 39: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
|
||||
23, // 40: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
|
||||
25, // 41: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
|
||||
27, // 42: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
|
||||
32, // 43: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
|
||||
34, // 44: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
|
||||
36, // 45: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
|
||||
38, // 46: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
|
||||
40, // 47: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
|
||||
44, // 48: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
|
||||
42, // 49: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
42, // 50: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
51, // 51: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
|
||||
53, // 52: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
|
||||
56, // 53: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
|
||||
58, // 54: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
|
||||
60, // 55: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
|
||||
62, // 56: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
|
||||
65, // 57: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
|
||||
2, // 58: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
|
||||
4, // 59: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
|
||||
14, // 60: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
|
||||
16, // 61: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
|
||||
18, // 62: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
|
||||
20, // 63: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
|
||||
22, // 64: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
|
||||
24, // 65: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
|
||||
26, // 66: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
|
||||
30, // 67: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
|
||||
33, // 68: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
|
||||
35, // 69: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
|
||||
37, // 70: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
|
||||
39, // 71: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
|
||||
41, // 72: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
|
||||
45, // 73: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
|
||||
43, // 74: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
43, // 75: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
52, // 76: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
|
||||
54, // 77: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
|
||||
57, // 78: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
|
||||
59, // 79: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
|
||||
61, // 80: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
|
||||
63, // 81: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
|
||||
66, // 82: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
|
||||
58, // [58:83] is the sub-list for method output_type
|
||||
33, // [33:58] is the sub-list for method input_type
|
||||
33, // [33:33] is the sub-list for extension type_name
|
||||
33, // [33:33] is the sub-list for extension extendee
|
||||
0, // [0:33] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_filer_proto_init() }
|
||||
@@ -5040,7 +5054,7 @@ func file_filer_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_filer_proto_rawDesc), len(file_filer_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 70,
|
||||
NumMessages: 71,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -350,11 +350,13 @@ message VacuumVolumeResponse {
|
||||
}
|
||||
|
||||
message DisableVacuumRequest {
|
||||
bool by_plugin = 1;
|
||||
}
|
||||
message DisableVacuumResponse {
|
||||
}
|
||||
|
||||
message EnableVacuumRequest {
|
||||
bool by_plugin = 1;
|
||||
}
|
||||
message EnableVacuumResponse {
|
||||
}
|
||||
|
||||
@@ -2650,6 +2650,7 @@ func (*VacuumVolumeResponse) Descriptor() ([]byte, []int) {
|
||||
|
||||
type DisableVacuumRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ByPlugin bool `protobuf:"varint,1,opt,name=by_plugin,json=byPlugin,proto3" json:"by_plugin,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2684,6 +2685,13 @@ func (*DisableVacuumRequest) Descriptor() ([]byte, []int) {
|
||||
return file_master_proto_rawDescGZIP(), []int{37}
|
||||
}
|
||||
|
||||
func (x *DisableVacuumRequest) GetByPlugin() bool {
|
||||
if x != nil {
|
||||
return x.ByPlugin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type DisableVacuumResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -2722,6 +2730,7 @@ func (*DisableVacuumResponse) Descriptor() ([]byte, []int) {
|
||||
|
||||
type EnableVacuumRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ByPlugin bool `protobuf:"varint,1,opt,name=by_plugin,json=byPlugin,proto3" json:"by_plugin,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2756,6 +2765,13 @@ func (*EnableVacuumRequest) Descriptor() ([]byte, []int) {
|
||||
return file_master_proto_rawDescGZIP(), []int{39}
|
||||
}
|
||||
|
||||
func (x *EnableVacuumRequest) GetByPlugin() bool {
|
||||
if x != nil {
|
||||
return x.ByPlugin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EnableVacuumResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -2973,16 +2989,17 @@ func (*GetMasterConfigurationRequest) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type GetMasterConfigurationResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MetricsAddress string `protobuf:"bytes,1,opt,name=metrics_address,json=metricsAddress,proto3" json:"metrics_address,omitempty"`
|
||||
MetricsIntervalSeconds uint32 `protobuf:"varint,2,opt,name=metrics_interval_seconds,json=metricsIntervalSeconds,proto3" json:"metrics_interval_seconds,omitempty"`
|
||||
StorageBackends []*StorageBackend `protobuf:"bytes,3,rep,name=storage_backends,json=storageBackends,proto3" json:"storage_backends,omitempty"`
|
||||
DefaultReplication string `protobuf:"bytes,4,opt,name=default_replication,json=defaultReplication,proto3" json:"default_replication,omitempty"`
|
||||
Leader string `protobuf:"bytes,5,opt,name=leader,proto3" json:"leader,omitempty"`
|
||||
VolumeSizeLimitMB uint32 `protobuf:"varint,6,opt,name=volume_size_limit_m_b,json=volumeSizeLimitMB,proto3" json:"volume_size_limit_m_b,omitempty"`
|
||||
VolumePreallocate bool `protobuf:"varint,7,opt,name=volume_preallocate,json=volumePreallocate,proto3" json:"volume_preallocate,omitempty"`
|
||||
MaintenanceScripts string `protobuf:"bytes,8,opt,name=maintenance_scripts,json=maintenanceScripts,proto3" json:"maintenance_scripts,omitempty"`
|
||||
MaintenanceSleepMinutes uint32 `protobuf:"varint,9,opt,name=maintenance_sleep_minutes,json=maintenanceSleepMinutes,proto3" json:"maintenance_sleep_minutes,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MetricsAddress string `protobuf:"bytes,1,opt,name=metrics_address,json=metricsAddress,proto3" json:"metrics_address,omitempty"`
|
||||
MetricsIntervalSeconds uint32 `protobuf:"varint,2,opt,name=metrics_interval_seconds,json=metricsIntervalSeconds,proto3" json:"metrics_interval_seconds,omitempty"`
|
||||
StorageBackends []*StorageBackend `protobuf:"bytes,3,rep,name=storage_backends,json=storageBackends,proto3" json:"storage_backends,omitempty"`
|
||||
DefaultReplication string `protobuf:"bytes,4,opt,name=default_replication,json=defaultReplication,proto3" json:"default_replication,omitempty"`
|
||||
Leader string `protobuf:"bytes,5,opt,name=leader,proto3" json:"leader,omitempty"`
|
||||
VolumeSizeLimitMB uint32 `protobuf:"varint,6,opt,name=volume_size_limit_m_b,json=volumeSizeLimitMB,proto3" json:"volume_size_limit_m_b,omitempty"`
|
||||
VolumePreallocate bool `protobuf:"varint,7,opt,name=volume_preallocate,json=volumePreallocate,proto3" json:"volume_preallocate,omitempty"`
|
||||
// MIGRATION: fields 8-9 help migrate master.toml [master.maintenance] to admin script plugin. Remove after March 2027.
|
||||
MaintenanceScripts string `protobuf:"bytes,8,opt,name=maintenance_scripts,json=maintenanceScripts,proto3" json:"maintenance_scripts,omitempty"`
|
||||
MaintenanceSleepMinutes uint32 `protobuf:"varint,9,opt,name=maintenance_sleep_minutes,json=maintenanceSleepMinutes,proto3" json:"maintenance_sleep_minutes,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4525,10 +4542,12 @@ const file_master_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"collection\x18\x03 \x01(\tR\n" +
|
||||
"collection\"\x16\n" +
|
||||
"\x14VacuumVolumeResponse\"\x16\n" +
|
||||
"\x14DisableVacuumRequest\"\x17\n" +
|
||||
"\x15DisableVacuumResponse\"\x15\n" +
|
||||
"\x13EnableVacuumRequest\"\x16\n" +
|
||||
"\x14VacuumVolumeResponse\"3\n" +
|
||||
"\x14DisableVacuumRequest\x12\x1b\n" +
|
||||
"\tby_plugin\x18\x01 \x01(\bR\bbyPlugin\"\x17\n" +
|
||||
"\x15DisableVacuumResponse\"2\n" +
|
||||
"\x13EnableVacuumRequest\x12\x1b\n" +
|
||||
"\tby_plugin\x18\x01 \x01(\bR\bbyPlugin\"\x16\n" +
|
||||
"\x14EnableVacuumResponse\"\x93\x02\n" +
|
||||
"\x19VolumeMarkReadonlyRequest\x12\x0e\n" +
|
||||
"\x02ip\x18\x01 \x01(\tR\x02ip\x12\x12\n" +
|
||||
|
||||
@@ -73,4 +73,5 @@ message RemoteStorageLocation {
|
||||
string name = 1;
|
||||
string bucket = 2;
|
||||
string path = 3;
|
||||
int32 listing_cache_ttl_seconds = 4; // 0 = disabled; >0 enables on-demand directory listing with this TTL in seconds
|
||||
}
|
||||
|
||||
@@ -457,12 +457,13 @@ func (x *RemoteStorageMapping) GetPrimaryBucketStorageName() string {
|
||||
}
|
||||
|
||||
type RemoteStorageLocation struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Bucket string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
|
||||
Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Bucket string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
|
||||
Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
|
||||
ListingCacheTtlSeconds int32 `protobuf:"varint,4,opt,name=listing_cache_ttl_seconds,json=listingCacheTtlSeconds,proto3" json:"listing_cache_ttl_seconds,omitempty"` // 0 = disabled; >0 enables on-demand directory listing with this TTL in seconds
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RemoteStorageLocation) Reset() {
|
||||
@@ -516,6 +517,13 @@ func (x *RemoteStorageLocation) GetPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RemoteStorageLocation) GetListingCacheTtlSeconds() int32 {
|
||||
if x != nil {
|
||||
return x.ListingCacheTtlSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var File_remote_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_remote_proto_rawDesc = "" +
|
||||
@@ -573,11 +581,12 @@ const file_remote_proto_rawDesc = "" +
|
||||
"\x1bprimary_bucket_storage_name\x18\x02 \x01(\tR\x18primaryBucketStorageName\x1a]\n" +
|
||||
"\rMappingsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x126\n" +
|
||||
"\x05value\x18\x02 \x01(\v2 .remote_pb.RemoteStorageLocationR\x05value:\x028\x01\"W\n" +
|
||||
"\x05value\x18\x02 \x01(\v2 .remote_pb.RemoteStorageLocationR\x05value:\x028\x01\"\x92\x01\n" +
|
||||
"\x15RemoteStorageLocation\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06bucket\x18\x02 \x01(\tR\x06bucket\x12\x12\n" +
|
||||
"\x04path\x18\x03 \x01(\tR\x04pathBP\n" +
|
||||
"\x04path\x18\x03 \x01(\tR\x04path\x129\n" +
|
||||
"\x19listing_cache_ttl_seconds\x18\x04 \x01(\x05R\x16listingCacheTtlSecondsBP\n" +
|
||||
"\x10seaweedfs.clientB\n" +
|
||||
"FilerProtoZ0github.com/seaweedfs/seaweedfs/weed/pb/remote_pbb\x06proto3"
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ message TaskParams {
|
||||
ErasureCodingTaskParams erasure_coding_params = 10;
|
||||
BalanceTaskParams balance_params = 11;
|
||||
ReplicationTaskParams replication_params = 12;
|
||||
EcBalanceTaskParams ec_balance_params = 13;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +308,7 @@ message TaskPolicy {
|
||||
ErasureCodingTaskConfig erasure_coding_config = 6;
|
||||
BalanceTaskConfig balance_config = 7;
|
||||
ReplicationTaskConfig replication_config = 8;
|
||||
EcBalanceTaskConfig ec_balance_config = 9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +341,34 @@ message ReplicationTaskConfig {
|
||||
int32 target_replica_count = 1; // Target number of replicas
|
||||
}
|
||||
|
||||
// EcBalanceTaskParams for EC shard balancing operations
|
||||
message EcBalanceTaskParams {
|
||||
string disk_type = 1; // Disk type filter (hdd, ssd, "")
|
||||
int32 max_parallelization = 2; // Max parallel shard moves within a batch
|
||||
int32 timeout_seconds = 3; // Operation timeout per move
|
||||
repeated EcShardMoveSpec moves = 4; // Batch: multiple shard moves in one job
|
||||
}
|
||||
|
||||
// EcShardMoveSpec describes a single EC shard move within a batch
|
||||
message EcShardMoveSpec {
|
||||
uint32 volume_id = 1; // EC volume ID
|
||||
uint32 shard_id = 2; // Shard ID (0-13)
|
||||
string collection = 3; // Collection name
|
||||
string source_node = 4; // Source server address
|
||||
uint32 source_disk_id = 5; // Source disk ID
|
||||
string target_node = 6; // Target server address
|
||||
uint32 target_disk_id = 7; // Target disk ID
|
||||
}
|
||||
|
||||
// EcBalanceTaskConfig contains EC balance-specific configuration
|
||||
message EcBalanceTaskConfig {
|
||||
double imbalance_threshold = 1; // Threshold for triggering EC shard rebalancing
|
||||
int32 min_server_count = 2; // Minimum number of servers required
|
||||
string collection_filter = 3; // Collection filter
|
||||
string disk_type = 4; // Disk type filter
|
||||
repeated string preferred_tags = 5; // Preferred disk tags for placement
|
||||
}
|
||||
|
||||
// ========== Task Persistence Messages ==========
|
||||
|
||||
// MaintenanceTaskData represents complete task state for persistence
|
||||
|
||||
+370
-66
@@ -821,6 +821,7 @@ type TaskParams struct {
|
||||
// *TaskParams_ErasureCodingParams
|
||||
// *TaskParams_BalanceParams
|
||||
// *TaskParams_ReplicationParams
|
||||
// *TaskParams_EcBalanceParams
|
||||
TaskParams isTaskParams_TaskParams `protobuf_oneof:"task_params"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -955,6 +956,15 @@ func (x *TaskParams) GetReplicationParams() *ReplicationTaskParams {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TaskParams) GetEcBalanceParams() *EcBalanceTaskParams {
|
||||
if x != nil {
|
||||
if x, ok := x.TaskParams.(*TaskParams_EcBalanceParams); ok {
|
||||
return x.EcBalanceParams
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isTaskParams_TaskParams interface {
|
||||
isTaskParams_TaskParams()
|
||||
}
|
||||
@@ -975,6 +985,10 @@ type TaskParams_ReplicationParams struct {
|
||||
ReplicationParams *ReplicationTaskParams `protobuf:"bytes,12,opt,name=replication_params,json=replicationParams,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TaskParams_EcBalanceParams struct {
|
||||
EcBalanceParams *EcBalanceTaskParams `protobuf:"bytes,13,opt,name=ec_balance_params,json=ecBalanceParams,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*TaskParams_VacuumParams) isTaskParams_TaskParams() {}
|
||||
|
||||
func (*TaskParams_ErasureCodingParams) isTaskParams_TaskParams() {}
|
||||
@@ -983,6 +997,8 @@ func (*TaskParams_BalanceParams) isTaskParams_TaskParams() {}
|
||||
|
||||
func (*TaskParams_ReplicationParams) isTaskParams_TaskParams() {}
|
||||
|
||||
func (*TaskParams_EcBalanceParams) isTaskParams_TaskParams() {}
|
||||
|
||||
// VacuumTaskParams for vacuum operations
|
||||
type VacuumTaskParams struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2480,6 +2496,7 @@ type TaskPolicy struct {
|
||||
// *TaskPolicy_ErasureCodingConfig
|
||||
// *TaskPolicy_BalanceConfig
|
||||
// *TaskPolicy_ReplicationConfig
|
||||
// *TaskPolicy_EcBalanceConfig
|
||||
TaskConfig isTaskPolicy_TaskConfig `protobuf_oneof:"task_config"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -2586,6 +2603,15 @@ func (x *TaskPolicy) GetReplicationConfig() *ReplicationTaskConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TaskPolicy) GetEcBalanceConfig() *EcBalanceTaskConfig {
|
||||
if x != nil {
|
||||
if x, ok := x.TaskConfig.(*TaskPolicy_EcBalanceConfig); ok {
|
||||
return x.EcBalanceConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isTaskPolicy_TaskConfig interface {
|
||||
isTaskPolicy_TaskConfig()
|
||||
}
|
||||
@@ -2606,6 +2632,10 @@ type TaskPolicy_ReplicationConfig struct {
|
||||
ReplicationConfig *ReplicationTaskConfig `protobuf:"bytes,8,opt,name=replication_config,json=replicationConfig,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TaskPolicy_EcBalanceConfig struct {
|
||||
EcBalanceConfig *EcBalanceTaskConfig `protobuf:"bytes,9,opt,name=ec_balance_config,json=ecBalanceConfig,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*TaskPolicy_VacuumConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
func (*TaskPolicy_ErasureCodingConfig) isTaskPolicy_TaskConfig() {}
|
||||
@@ -2614,6 +2644,8 @@ func (*TaskPolicy_BalanceConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
func (*TaskPolicy_ReplicationConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
func (*TaskPolicy_EcBalanceConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
// VacuumTaskConfig contains vacuum-specific configuration
|
||||
type VacuumTaskConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2850,6 +2882,245 @@ func (x *ReplicationTaskConfig) GetTargetReplicaCount() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// EcBalanceTaskParams for EC shard balancing operations
|
||||
type EcBalanceTaskParams struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DiskType string `protobuf:"bytes,1,opt,name=disk_type,json=diskType,proto3" json:"disk_type,omitempty"` // Disk type filter (hdd, ssd, "")
|
||||
MaxParallelization int32 `protobuf:"varint,2,opt,name=max_parallelization,json=maxParallelization,proto3" json:"max_parallelization,omitempty"` // Max parallel shard moves within a batch
|
||||
TimeoutSeconds int32 `protobuf:"varint,3,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` // Operation timeout per move
|
||||
Moves []*EcShardMoveSpec `protobuf:"bytes,4,rep,name=moves,proto3" json:"moves,omitempty"` // Batch: multiple shard moves in one job
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) Reset() {
|
||||
*x = EcBalanceTaskParams{}
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EcBalanceTaskParams) ProtoMessage() {}
|
||||
|
||||
func (x *EcBalanceTaskParams) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EcBalanceTaskParams.ProtoReflect.Descriptor instead.
|
||||
func (*EcBalanceTaskParams) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{32}
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetDiskType() string {
|
||||
if x != nil {
|
||||
return x.DiskType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetMaxParallelization() int32 {
|
||||
if x != nil {
|
||||
return x.MaxParallelization
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetTimeoutSeconds() int32 {
|
||||
if x != nil {
|
||||
return x.TimeoutSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetMoves() []*EcShardMoveSpec {
|
||||
if x != nil {
|
||||
return x.Moves
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EcShardMoveSpec describes a single EC shard move within a batch
|
||||
type EcShardMoveSpec struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
VolumeId uint32 `protobuf:"varint,1,opt,name=volume_id,json=volumeId,proto3" json:"volume_id,omitempty"` // EC volume ID
|
||||
ShardId uint32 `protobuf:"varint,2,opt,name=shard_id,json=shardId,proto3" json:"shard_id,omitempty"` // Shard ID (0-13)
|
||||
Collection string `protobuf:"bytes,3,opt,name=collection,proto3" json:"collection,omitempty"` // Collection name
|
||||
SourceNode string `protobuf:"bytes,4,opt,name=source_node,json=sourceNode,proto3" json:"source_node,omitempty"` // Source server address
|
||||
SourceDiskId uint32 `protobuf:"varint,5,opt,name=source_disk_id,json=sourceDiskId,proto3" json:"source_disk_id,omitempty"` // Source disk ID
|
||||
TargetNode string `protobuf:"bytes,6,opt,name=target_node,json=targetNode,proto3" json:"target_node,omitempty"` // Target server address
|
||||
TargetDiskId uint32 `protobuf:"varint,7,opt,name=target_disk_id,json=targetDiskId,proto3" json:"target_disk_id,omitempty"` // Target disk ID
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) Reset() {
|
||||
*x = EcShardMoveSpec{}
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EcShardMoveSpec) ProtoMessage() {}
|
||||
|
||||
func (x *EcShardMoveSpec) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EcShardMoveSpec.ProtoReflect.Descriptor instead.
|
||||
func (*EcShardMoveSpec) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{33}
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetVolumeId() uint32 {
|
||||
if x != nil {
|
||||
return x.VolumeId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetShardId() uint32 {
|
||||
if x != nil {
|
||||
return x.ShardId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetCollection() string {
|
||||
if x != nil {
|
||||
return x.Collection
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetSourceNode() string {
|
||||
if x != nil {
|
||||
return x.SourceNode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetSourceDiskId() uint32 {
|
||||
if x != nil {
|
||||
return x.SourceDiskId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetTargetNode() string {
|
||||
if x != nil {
|
||||
return x.TargetNode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetTargetDiskId() uint32 {
|
||||
if x != nil {
|
||||
return x.TargetDiskId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// EcBalanceTaskConfig contains EC balance-specific configuration
|
||||
type EcBalanceTaskConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ImbalanceThreshold float64 `protobuf:"fixed64,1,opt,name=imbalance_threshold,json=imbalanceThreshold,proto3" json:"imbalance_threshold,omitempty"` // Threshold for triggering EC shard rebalancing
|
||||
MinServerCount int32 `protobuf:"varint,2,opt,name=min_server_count,json=minServerCount,proto3" json:"min_server_count,omitempty"` // Minimum number of servers required
|
||||
CollectionFilter string `protobuf:"bytes,3,opt,name=collection_filter,json=collectionFilter,proto3" json:"collection_filter,omitempty"` // Collection filter
|
||||
DiskType string `protobuf:"bytes,4,opt,name=disk_type,json=diskType,proto3" json:"disk_type,omitempty"` // Disk type filter
|
||||
PreferredTags []string `protobuf:"bytes,5,rep,name=preferred_tags,json=preferredTags,proto3" json:"preferred_tags,omitempty"` // Preferred disk tags for placement
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) Reset() {
|
||||
*x = EcBalanceTaskConfig{}
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EcBalanceTaskConfig) ProtoMessage() {}
|
||||
|
||||
func (x *EcBalanceTaskConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EcBalanceTaskConfig.ProtoReflect.Descriptor instead.
|
||||
func (*EcBalanceTaskConfig) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{34}
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetImbalanceThreshold() float64 {
|
||||
if x != nil {
|
||||
return x.ImbalanceThreshold
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetMinServerCount() int32 {
|
||||
if x != nil {
|
||||
return x.MinServerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetCollectionFilter() string {
|
||||
if x != nil {
|
||||
return x.CollectionFilter
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetDiskType() string {
|
||||
if x != nil {
|
||||
return x.DiskType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetPreferredTags() []string {
|
||||
if x != nil {
|
||||
return x.PreferredTags
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaintenanceTaskData represents complete task state for persistence
|
||||
type MaintenanceTaskData struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2884,7 +3155,7 @@ type MaintenanceTaskData struct {
|
||||
|
||||
func (x *MaintenanceTaskData) Reset() {
|
||||
*x = MaintenanceTaskData{}
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2896,7 +3167,7 @@ func (x *MaintenanceTaskData) String() string {
|
||||
func (*MaintenanceTaskData) ProtoMessage() {}
|
||||
|
||||
func (x *MaintenanceTaskData) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2909,7 +3180,7 @@ func (x *MaintenanceTaskData) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MaintenanceTaskData.ProtoReflect.Descriptor instead.
|
||||
func (*MaintenanceTaskData) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{32}
|
||||
return file_worker_proto_rawDescGZIP(), []int{35}
|
||||
}
|
||||
|
||||
func (x *MaintenanceTaskData) GetId() string {
|
||||
@@ -3094,7 +3365,7 @@ type TaskAssignmentRecord struct {
|
||||
|
||||
func (x *TaskAssignmentRecord) Reset() {
|
||||
*x = TaskAssignmentRecord{}
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3106,7 +3377,7 @@ func (x *TaskAssignmentRecord) String() string {
|
||||
func (*TaskAssignmentRecord) ProtoMessage() {}
|
||||
|
||||
func (x *TaskAssignmentRecord) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3119,7 +3390,7 @@ func (x *TaskAssignmentRecord) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TaskAssignmentRecord.ProtoReflect.Descriptor instead.
|
||||
func (*TaskAssignmentRecord) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{33}
|
||||
return file_worker_proto_rawDescGZIP(), []int{36}
|
||||
}
|
||||
|
||||
func (x *TaskAssignmentRecord) GetWorkerId() string {
|
||||
@@ -3171,7 +3442,7 @@ type TaskCreationMetrics struct {
|
||||
|
||||
func (x *TaskCreationMetrics) Reset() {
|
||||
*x = TaskCreationMetrics{}
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
mi := &file_worker_proto_msgTypes[37]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3183,7 +3454,7 @@ func (x *TaskCreationMetrics) String() string {
|
||||
func (*TaskCreationMetrics) ProtoMessage() {}
|
||||
|
||||
func (x *TaskCreationMetrics) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
mi := &file_worker_proto_msgTypes[37]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3196,7 +3467,7 @@ func (x *TaskCreationMetrics) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TaskCreationMetrics.ProtoReflect.Descriptor instead.
|
||||
func (*TaskCreationMetrics) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{34}
|
||||
return file_worker_proto_rawDescGZIP(), []int{37}
|
||||
}
|
||||
|
||||
func (x *TaskCreationMetrics) GetTriggerMetric() string {
|
||||
@@ -3253,7 +3524,7 @@ type VolumeHealthMetrics struct {
|
||||
|
||||
func (x *VolumeHealthMetrics) Reset() {
|
||||
*x = VolumeHealthMetrics{}
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
mi := &file_worker_proto_msgTypes[38]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3265,7 +3536,7 @@ func (x *VolumeHealthMetrics) String() string {
|
||||
func (*VolumeHealthMetrics) ProtoMessage() {}
|
||||
|
||||
func (x *VolumeHealthMetrics) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
mi := &file_worker_proto_msgTypes[38]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3278,7 +3549,7 @@ func (x *VolumeHealthMetrics) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use VolumeHealthMetrics.ProtoReflect.Descriptor instead.
|
||||
func (*VolumeHealthMetrics) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{35}
|
||||
return file_worker_proto_rawDescGZIP(), []int{38}
|
||||
}
|
||||
|
||||
func (x *VolumeHealthMetrics) GetTotalSize() uint64 {
|
||||
@@ -3363,7 +3634,7 @@ type TaskStateFile struct {
|
||||
|
||||
func (x *TaskStateFile) Reset() {
|
||||
*x = TaskStateFile{}
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
mi := &file_worker_proto_msgTypes[39]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3375,7 +3646,7 @@ func (x *TaskStateFile) String() string {
|
||||
func (*TaskStateFile) ProtoMessage() {}
|
||||
|
||||
func (x *TaskStateFile) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
mi := &file_worker_proto_msgTypes[39]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3388,7 +3659,7 @@ func (x *TaskStateFile) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TaskStateFile.ProtoReflect.Descriptor instead.
|
||||
func (*TaskStateFile) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{36}
|
||||
return file_worker_proto_rawDescGZIP(), []int{39}
|
||||
}
|
||||
|
||||
func (x *TaskStateFile) GetTask() *MaintenanceTaskData {
|
||||
@@ -3477,7 +3748,7 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\bmetadata\x18\x06 \x03(\v2'.worker_pb.TaskAssignment.MetadataEntryR\bmetadata\x1a;\n" +
|
||||
"\rMetadataEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe1\x04\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xaf\x05\n" +
|
||||
"\n" +
|
||||
"TaskParams\x12\x17\n" +
|
||||
"\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1b\n" +
|
||||
@@ -3496,7 +3767,8 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\x15erasure_coding_params\x18\n" +
|
||||
" \x01(\v2\".worker_pb.ErasureCodingTaskParamsH\x00R\x13erasureCodingParams\x12E\n" +
|
||||
"\x0ebalance_params\x18\v \x01(\v2\x1c.worker_pb.BalanceTaskParamsH\x00R\rbalanceParams\x12Q\n" +
|
||||
"\x12replication_params\x18\f \x01(\v2 .worker_pb.ReplicationTaskParamsH\x00R\x11replicationParamsB\r\n" +
|
||||
"\x12replication_params\x18\f \x01(\v2 .worker_pb.ReplicationTaskParamsH\x00R\x11replicationParams\x12L\n" +
|
||||
"\x11ec_balance_params\x18\r \x01(\v2\x1e.worker_pb.EcBalanceTaskParamsH\x00R\x0fecBalanceParamsB\r\n" +
|
||||
"\vtask_params\"\xcb\x01\n" +
|
||||
"\x10VacuumTaskParams\x12+\n" +
|
||||
"\x11garbage_threshold\x18\x01 \x01(\x01R\x10garbageThreshold\x12!\n" +
|
||||
@@ -3658,7 +3930,7 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\x1edefault_check_interval_seconds\x18\x04 \x01(\x05R\x1bdefaultCheckIntervalSeconds\x1aV\n" +
|
||||
"\x11TaskPoliciesEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12+\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x15.worker_pb.TaskPolicyR\x05value:\x028\x01\"\x82\x04\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x15.worker_pb.TaskPolicyR\x05value:\x028\x01\"\xd0\x04\n" +
|
||||
"\n" +
|
||||
"TaskPolicy\x12\x18\n" +
|
||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12%\n" +
|
||||
@@ -3668,7 +3940,8 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\rvacuum_config\x18\x05 \x01(\v2\x1b.worker_pb.VacuumTaskConfigH\x00R\fvacuumConfig\x12X\n" +
|
||||
"\x15erasure_coding_config\x18\x06 \x01(\v2\".worker_pb.ErasureCodingTaskConfigH\x00R\x13erasureCodingConfig\x12E\n" +
|
||||
"\x0ebalance_config\x18\a \x01(\v2\x1c.worker_pb.BalanceTaskConfigH\x00R\rbalanceConfig\x12Q\n" +
|
||||
"\x12replication_config\x18\b \x01(\v2 .worker_pb.ReplicationTaskConfigH\x00R\x11replicationConfigB\r\n" +
|
||||
"\x12replication_config\x18\b \x01(\v2 .worker_pb.ReplicationTaskConfigH\x00R\x11replicationConfig\x12L\n" +
|
||||
"\x11ec_balance_config\x18\t \x01(\v2\x1e.worker_pb.EcBalanceTaskConfigH\x00R\x0fecBalanceConfigB\r\n" +
|
||||
"\vtask_config\"\xa2\x01\n" +
|
||||
"\x10VacuumTaskConfig\x12+\n" +
|
||||
"\x11garbage_threshold\x18\x01 \x01(\x01R\x10garbageThreshold\x12/\n" +
|
||||
@@ -3684,7 +3957,30 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\x13imbalance_threshold\x18\x01 \x01(\x01R\x12imbalanceThreshold\x12(\n" +
|
||||
"\x10min_server_count\x18\x02 \x01(\x05R\x0eminServerCount\"I\n" +
|
||||
"\x15ReplicationTaskConfig\x120\n" +
|
||||
"\x14target_replica_count\x18\x01 \x01(\x05R\x12targetReplicaCount\"\xae\a\n" +
|
||||
"\x14target_replica_count\x18\x01 \x01(\x05R\x12targetReplicaCount\"\xbe\x01\n" +
|
||||
"\x13EcBalanceTaskParams\x12\x1b\n" +
|
||||
"\tdisk_type\x18\x01 \x01(\tR\bdiskType\x12/\n" +
|
||||
"\x13max_parallelization\x18\x02 \x01(\x05R\x12maxParallelization\x12'\n" +
|
||||
"\x0ftimeout_seconds\x18\x03 \x01(\x05R\x0etimeoutSeconds\x120\n" +
|
||||
"\x05moves\x18\x04 \x03(\v2\x1a.worker_pb.EcShardMoveSpecR\x05moves\"\xf7\x01\n" +
|
||||
"\x0fEcShardMoveSpec\x12\x1b\n" +
|
||||
"\tvolume_id\x18\x01 \x01(\rR\bvolumeId\x12\x19\n" +
|
||||
"\bshard_id\x18\x02 \x01(\rR\ashardId\x12\x1e\n" +
|
||||
"\n" +
|
||||
"collection\x18\x03 \x01(\tR\n" +
|
||||
"collection\x12\x1f\n" +
|
||||
"\vsource_node\x18\x04 \x01(\tR\n" +
|
||||
"sourceNode\x12$\n" +
|
||||
"\x0esource_disk_id\x18\x05 \x01(\rR\fsourceDiskId\x12\x1f\n" +
|
||||
"\vtarget_node\x18\x06 \x01(\tR\n" +
|
||||
"targetNode\x12$\n" +
|
||||
"\x0etarget_disk_id\x18\a \x01(\rR\ftargetDiskId\"\xe1\x01\n" +
|
||||
"\x13EcBalanceTaskConfig\x12/\n" +
|
||||
"\x13imbalance_threshold\x18\x01 \x01(\x01R\x12imbalanceThreshold\x12(\n" +
|
||||
"\x10min_server_count\x18\x02 \x01(\x05R\x0eminServerCount\x12+\n" +
|
||||
"\x11collection_filter\x18\x03 \x01(\tR\x10collectionFilter\x12\x1b\n" +
|
||||
"\tdisk_type\x18\x04 \x01(\tR\bdiskType\x12%\n" +
|
||||
"\x0epreferred_tags\x18\x05 \x03(\tR\rpreferredTags\"\xae\a\n" +
|
||||
"\x13MaintenanceTaskData\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04type\x18\x02 \x01(\tR\x04type\x12\x1a\n" +
|
||||
@@ -3773,7 +4069,7 @@ func file_worker_proto_rawDescGZIP() []byte {
|
||||
return file_worker_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_worker_proto_msgTypes = make([]protoimpl.MessageInfo, 46)
|
||||
var file_worker_proto_msgTypes = make([]protoimpl.MessageInfo, 49)
|
||||
var file_worker_proto_goTypes = []any{
|
||||
(*WorkerMessage)(nil), // 0: worker_pb.WorkerMessage
|
||||
(*AdminMessage)(nil), // 1: worker_pb.AdminMessage
|
||||
@@ -3807,20 +4103,23 @@ var file_worker_proto_goTypes = []any{
|
||||
(*ErasureCodingTaskConfig)(nil), // 29: worker_pb.ErasureCodingTaskConfig
|
||||
(*BalanceTaskConfig)(nil), // 30: worker_pb.BalanceTaskConfig
|
||||
(*ReplicationTaskConfig)(nil), // 31: worker_pb.ReplicationTaskConfig
|
||||
(*MaintenanceTaskData)(nil), // 32: worker_pb.MaintenanceTaskData
|
||||
(*TaskAssignmentRecord)(nil), // 33: worker_pb.TaskAssignmentRecord
|
||||
(*TaskCreationMetrics)(nil), // 34: worker_pb.TaskCreationMetrics
|
||||
(*VolumeHealthMetrics)(nil), // 35: worker_pb.VolumeHealthMetrics
|
||||
(*TaskStateFile)(nil), // 36: worker_pb.TaskStateFile
|
||||
nil, // 37: worker_pb.WorkerRegistration.MetadataEntry
|
||||
nil, // 38: worker_pb.TaskAssignment.MetadataEntry
|
||||
nil, // 39: worker_pb.TaskUpdate.MetadataEntry
|
||||
nil, // 40: worker_pb.TaskComplete.ResultMetadataEntry
|
||||
nil, // 41: worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
nil, // 42: worker_pb.TaskLogEntry.FieldsEntry
|
||||
nil, // 43: worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
nil, // 44: worker_pb.MaintenanceTaskData.TagsEntry
|
||||
nil, // 45: worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
(*EcBalanceTaskParams)(nil), // 32: worker_pb.EcBalanceTaskParams
|
||||
(*EcShardMoveSpec)(nil), // 33: worker_pb.EcShardMoveSpec
|
||||
(*EcBalanceTaskConfig)(nil), // 34: worker_pb.EcBalanceTaskConfig
|
||||
(*MaintenanceTaskData)(nil), // 35: worker_pb.MaintenanceTaskData
|
||||
(*TaskAssignmentRecord)(nil), // 36: worker_pb.TaskAssignmentRecord
|
||||
(*TaskCreationMetrics)(nil), // 37: worker_pb.TaskCreationMetrics
|
||||
(*VolumeHealthMetrics)(nil), // 38: worker_pb.VolumeHealthMetrics
|
||||
(*TaskStateFile)(nil), // 39: worker_pb.TaskStateFile
|
||||
nil, // 40: worker_pb.WorkerRegistration.MetadataEntry
|
||||
nil, // 41: worker_pb.TaskAssignment.MetadataEntry
|
||||
nil, // 42: worker_pb.TaskUpdate.MetadataEntry
|
||||
nil, // 43: worker_pb.TaskComplete.ResultMetadataEntry
|
||||
nil, // 44: worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
nil, // 45: worker_pb.TaskLogEntry.FieldsEntry
|
||||
nil, // 46: worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
nil, // 47: worker_pb.MaintenanceTaskData.TagsEntry
|
||||
nil, // 48: worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
}
|
||||
var file_worker_proto_depIdxs = []int32{
|
||||
2, // 0: worker_pb.WorkerMessage.registration:type_name -> worker_pb.WorkerRegistration
|
||||
@@ -3836,43 +4135,46 @@ var file_worker_proto_depIdxs = []int32{
|
||||
18, // 10: worker_pb.AdminMessage.task_cancellation:type_name -> worker_pb.TaskCancellation
|
||||
20, // 11: worker_pb.AdminMessage.admin_shutdown:type_name -> worker_pb.AdminShutdown
|
||||
21, // 12: worker_pb.AdminMessage.task_log_request:type_name -> worker_pb.TaskLogRequest
|
||||
37, // 13: worker_pb.WorkerRegistration.metadata:type_name -> worker_pb.WorkerRegistration.MetadataEntry
|
||||
40, // 13: worker_pb.WorkerRegistration.metadata:type_name -> worker_pb.WorkerRegistration.MetadataEntry
|
||||
8, // 14: worker_pb.TaskAssignment.params:type_name -> worker_pb.TaskParams
|
||||
38, // 15: worker_pb.TaskAssignment.metadata:type_name -> worker_pb.TaskAssignment.MetadataEntry
|
||||
41, // 15: worker_pb.TaskAssignment.metadata:type_name -> worker_pb.TaskAssignment.MetadataEntry
|
||||
11, // 16: worker_pb.TaskParams.sources:type_name -> worker_pb.TaskSource
|
||||
12, // 17: worker_pb.TaskParams.targets:type_name -> worker_pb.TaskTarget
|
||||
9, // 18: worker_pb.TaskParams.vacuum_params:type_name -> worker_pb.VacuumTaskParams
|
||||
10, // 19: worker_pb.TaskParams.erasure_coding_params:type_name -> worker_pb.ErasureCodingTaskParams
|
||||
14, // 20: worker_pb.TaskParams.balance_params:type_name -> worker_pb.BalanceTaskParams
|
||||
15, // 21: worker_pb.TaskParams.replication_params:type_name -> worker_pb.ReplicationTaskParams
|
||||
13, // 22: worker_pb.BalanceTaskParams.moves:type_name -> worker_pb.BalanceMoveSpec
|
||||
39, // 23: worker_pb.TaskUpdate.metadata:type_name -> worker_pb.TaskUpdate.MetadataEntry
|
||||
40, // 24: worker_pb.TaskComplete.result_metadata:type_name -> worker_pb.TaskComplete.ResultMetadataEntry
|
||||
23, // 25: worker_pb.TaskLogResponse.metadata:type_name -> worker_pb.TaskLogMetadata
|
||||
24, // 26: worker_pb.TaskLogResponse.log_entries:type_name -> worker_pb.TaskLogEntry
|
||||
41, // 27: worker_pb.TaskLogMetadata.custom_data:type_name -> worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
42, // 28: worker_pb.TaskLogEntry.fields:type_name -> worker_pb.TaskLogEntry.FieldsEntry
|
||||
26, // 29: worker_pb.MaintenanceConfig.policy:type_name -> worker_pb.MaintenancePolicy
|
||||
43, // 30: worker_pb.MaintenancePolicy.task_policies:type_name -> worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
28, // 31: worker_pb.TaskPolicy.vacuum_config:type_name -> worker_pb.VacuumTaskConfig
|
||||
29, // 32: worker_pb.TaskPolicy.erasure_coding_config:type_name -> worker_pb.ErasureCodingTaskConfig
|
||||
30, // 33: worker_pb.TaskPolicy.balance_config:type_name -> worker_pb.BalanceTaskConfig
|
||||
31, // 34: worker_pb.TaskPolicy.replication_config:type_name -> worker_pb.ReplicationTaskConfig
|
||||
8, // 35: worker_pb.MaintenanceTaskData.typed_params:type_name -> worker_pb.TaskParams
|
||||
33, // 36: worker_pb.MaintenanceTaskData.assignment_history:type_name -> worker_pb.TaskAssignmentRecord
|
||||
44, // 37: worker_pb.MaintenanceTaskData.tags:type_name -> worker_pb.MaintenanceTaskData.TagsEntry
|
||||
34, // 38: worker_pb.MaintenanceTaskData.creation_metrics:type_name -> worker_pb.TaskCreationMetrics
|
||||
35, // 39: worker_pb.TaskCreationMetrics.volume_metrics:type_name -> worker_pb.VolumeHealthMetrics
|
||||
45, // 40: worker_pb.TaskCreationMetrics.additional_data:type_name -> worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
32, // 41: worker_pb.TaskStateFile.task:type_name -> worker_pb.MaintenanceTaskData
|
||||
27, // 42: worker_pb.MaintenancePolicy.TaskPoliciesEntry.value:type_name -> worker_pb.TaskPolicy
|
||||
0, // 43: worker_pb.WorkerService.WorkerStream:input_type -> worker_pb.WorkerMessage
|
||||
1, // 44: worker_pb.WorkerService.WorkerStream:output_type -> worker_pb.AdminMessage
|
||||
44, // [44:45] is the sub-list for method output_type
|
||||
43, // [43:44] is the sub-list for method input_type
|
||||
43, // [43:43] is the sub-list for extension type_name
|
||||
43, // [43:43] is the sub-list for extension extendee
|
||||
0, // [0:43] is the sub-list for field type_name
|
||||
32, // 22: worker_pb.TaskParams.ec_balance_params:type_name -> worker_pb.EcBalanceTaskParams
|
||||
13, // 23: worker_pb.BalanceTaskParams.moves:type_name -> worker_pb.BalanceMoveSpec
|
||||
42, // 24: worker_pb.TaskUpdate.metadata:type_name -> worker_pb.TaskUpdate.MetadataEntry
|
||||
43, // 25: worker_pb.TaskComplete.result_metadata:type_name -> worker_pb.TaskComplete.ResultMetadataEntry
|
||||
23, // 26: worker_pb.TaskLogResponse.metadata:type_name -> worker_pb.TaskLogMetadata
|
||||
24, // 27: worker_pb.TaskLogResponse.log_entries:type_name -> worker_pb.TaskLogEntry
|
||||
44, // 28: worker_pb.TaskLogMetadata.custom_data:type_name -> worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
45, // 29: worker_pb.TaskLogEntry.fields:type_name -> worker_pb.TaskLogEntry.FieldsEntry
|
||||
26, // 30: worker_pb.MaintenanceConfig.policy:type_name -> worker_pb.MaintenancePolicy
|
||||
46, // 31: worker_pb.MaintenancePolicy.task_policies:type_name -> worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
28, // 32: worker_pb.TaskPolicy.vacuum_config:type_name -> worker_pb.VacuumTaskConfig
|
||||
29, // 33: worker_pb.TaskPolicy.erasure_coding_config:type_name -> worker_pb.ErasureCodingTaskConfig
|
||||
30, // 34: worker_pb.TaskPolicy.balance_config:type_name -> worker_pb.BalanceTaskConfig
|
||||
31, // 35: worker_pb.TaskPolicy.replication_config:type_name -> worker_pb.ReplicationTaskConfig
|
||||
34, // 36: worker_pb.TaskPolicy.ec_balance_config:type_name -> worker_pb.EcBalanceTaskConfig
|
||||
33, // 37: worker_pb.EcBalanceTaskParams.moves:type_name -> worker_pb.EcShardMoveSpec
|
||||
8, // 38: worker_pb.MaintenanceTaskData.typed_params:type_name -> worker_pb.TaskParams
|
||||
36, // 39: worker_pb.MaintenanceTaskData.assignment_history:type_name -> worker_pb.TaskAssignmentRecord
|
||||
47, // 40: worker_pb.MaintenanceTaskData.tags:type_name -> worker_pb.MaintenanceTaskData.TagsEntry
|
||||
37, // 41: worker_pb.MaintenanceTaskData.creation_metrics:type_name -> worker_pb.TaskCreationMetrics
|
||||
38, // 42: worker_pb.TaskCreationMetrics.volume_metrics:type_name -> worker_pb.VolumeHealthMetrics
|
||||
48, // 43: worker_pb.TaskCreationMetrics.additional_data:type_name -> worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
35, // 44: worker_pb.TaskStateFile.task:type_name -> worker_pb.MaintenanceTaskData
|
||||
27, // 45: worker_pb.MaintenancePolicy.TaskPoliciesEntry.value:type_name -> worker_pb.TaskPolicy
|
||||
0, // 46: worker_pb.WorkerService.WorkerStream:input_type -> worker_pb.WorkerMessage
|
||||
1, // 47: worker_pb.WorkerService.WorkerStream:output_type -> worker_pb.AdminMessage
|
||||
47, // [47:48] is the sub-list for method output_type
|
||||
46, // [46:47] is the sub-list for method input_type
|
||||
46, // [46:46] is the sub-list for extension type_name
|
||||
46, // [46:46] is the sub-list for extension extendee
|
||||
0, // [0:46] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_worker_proto_init() }
|
||||
@@ -3902,12 +4204,14 @@ func file_worker_proto_init() {
|
||||
(*TaskParams_ErasureCodingParams)(nil),
|
||||
(*TaskParams_BalanceParams)(nil),
|
||||
(*TaskParams_ReplicationParams)(nil),
|
||||
(*TaskParams_EcBalanceParams)(nil),
|
||||
}
|
||||
file_worker_proto_msgTypes[27].OneofWrappers = []any{
|
||||
(*TaskPolicy_VacuumConfig)(nil),
|
||||
(*TaskPolicy_ErasureCodingConfig)(nil),
|
||||
(*TaskPolicy_BalanceConfig)(nil),
|
||||
(*TaskPolicy_ReplicationConfig)(nil),
|
||||
(*TaskPolicy_EcBalanceConfig)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
@@ -3915,7 +4219,7 @@ func file_worker_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_worker_proto_rawDesc), len(file_worker_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 46,
|
||||
NumMessages: 49,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
ecbalancetask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/ec_balance"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
ecBalanceMinImbalanceThreshold = 0.05
|
||||
ecBalanceMaxImbalanceThreshold = 0.5
|
||||
ecBalanceMinServerCount = 2
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "ec_balance",
|
||||
Category: CategoryDefault,
|
||||
Aliases: []string{"ec-balance", "ec.balance", "ec_shard_balance"},
|
||||
Build: func(opts HandlerBuildOptions) (JobHandler, error) {
|
||||
return NewECBalanceHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type ecBalanceWorkerConfig struct {
|
||||
TaskConfig *ecbalancetask.Config
|
||||
MinIntervalSeconds int
|
||||
}
|
||||
|
||||
// ECBalanceHandler is the plugin job handler for EC shard balancing.
|
||||
type ECBalanceHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
func NewECBalanceHandler(grpcDialOption grpc.DialOption) *ECBalanceHandler {
|
||||
return &ECBalanceHandler{grpcDialOption: grpcDialOption}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: "ec_balance",
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 3,
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Balance EC shard distribution across racks and nodes",
|
||||
Weight: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: "ec_balance",
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Detect and execute EC shard rebalancing across the cluster",
|
||||
Icon: "fas fa-balance-scale-left",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "ec-balance-admin",
|
||||
Title: "EC Shard Balance Admin Config",
|
||||
Description: "Admin-side controls for EC shard balance detection scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Optional filters applied before EC shard balance detection.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only balance EC shards in matching collections (wildcard supported).",
|
||||
Placeholder: "all collections",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only balance within matching data centers (wildcard supported).",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "disk_type",
|
||||
Label: "Disk Type",
|
||||
Description: "Only balance EC shards on this disk type (hdd, ssd, or empty for all).",
|
||||
Placeholder: "all disk types",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"collection_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"data_center_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"disk_type": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "ec-balance-worker",
|
||||
Title: "EC Shard Balance Worker Config",
|
||||
Description: "Worker-side detection thresholds and execution controls.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "thresholds",
|
||||
Title: "Detection Thresholds",
|
||||
Description: "Controls for when EC shard balance jobs should be proposed.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "imbalance_threshold",
|
||||
Label: "Imbalance Threshold",
|
||||
Description: "Minimum shard count imbalance ratio to trigger balancing (0.0-1.0).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMinImbalanceThreshold}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMaxImbalanceThreshold}},
|
||||
},
|
||||
{
|
||||
Name: "min_server_count",
|
||||
Label: "Minimum Server Count",
|
||||
Description: "Minimum servers required for EC shard balancing.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: ecBalanceMinServerCount}},
|
||||
},
|
||||
{
|
||||
Name: "min_interval_seconds",
|
||||
Label: "Minimum Detection Interval (s)",
|
||||
Description: "Skip detection if the last successful run is more recent than this interval.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: "preferred_tags",
|
||||
Label: "Preferred Tags",
|
||||
Description: "Comma-separated disk tags to prioritize for shard placement, ordered by preference.",
|
||||
Placeholder: "fast,ssd",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"min_interval_seconds": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 60 * 60}},
|
||||
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 60 * 30,
|
||||
DetectionTimeoutSeconds: 300,
|
||||
MaxJobsPerDetection: 500,
|
||||
GlobalExecutionConcurrency: 16,
|
||||
PerWorkerExecutionConcurrency: 4,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 30,
|
||||
JobTypeMaxRuntimeSeconds: 1800,
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"min_interval_seconds": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 60 * 60}},
|
||||
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Detect(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.RunDetectionRequest,
|
||||
sender DetectionSender,
|
||||
) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != "ec_balance" {
|
||||
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := deriveECBalanceWorkerConfig(request.GetWorkerConfigValues())
|
||||
if ShouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
minInterval := time.Duration(workerConfig.MinIntervalSeconds) * time.Second
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"skipped_by_interval",
|
||||
fmt.Sprintf("EC BALANCE: Detection skipped due to min interval (%s)", minInterval),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
"min_interval_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(workerConfig.MinIntervalSeconds)},
|
||||
},
|
||||
},
|
||||
))
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "ec_balance",
|
||||
Proposals: []*plugin_pb.JobProposal{},
|
||||
HasMore: false,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "ec_balance",
|
||||
Success: true,
|
||||
TotalProposals: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Apply admin-side scope filters
|
||||
collectionFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
|
||||
if collectionFilter != "" {
|
||||
workerConfig.TaskConfig.CollectionFilter = collectionFilter
|
||||
}
|
||||
dcFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
if dcFilter != "" {
|
||||
workerConfig.TaskConfig.DataCenterFilter = dcFilter
|
||||
}
|
||||
diskType := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "disk_type", ""))
|
||||
if diskType != "" {
|
||||
workerConfig.TaskConfig.DiskType = diskType
|
||||
}
|
||||
|
||||
masters := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
maxResults := int(request.MaxResults)
|
||||
if maxResults < 0 {
|
||||
maxResults = 0
|
||||
}
|
||||
|
||||
results, hasMore, err := ecbalancetask.Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if traceErr := emitECBalanceDecisionTrace(sender, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
|
||||
glog.Warningf("Plugin worker failed to emit ec_balance detection trace: %v", traceErr)
|
||||
}
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
|
||||
for _, result := range results {
|
||||
proposal, proposalErr := buildECBalanceProposal(result)
|
||||
if proposalErr != nil {
|
||||
glog.Warningf("Plugin worker skip invalid ec_balance proposal: %v", proposalErr)
|
||||
continue
|
||||
}
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "ec_balance",
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "ec_balance",
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Execute(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.ExecuteJobRequest,
|
||||
sender ExecutionSender,
|
||||
) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != "ec_balance" {
|
||||
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.Job.JobType)
|
||||
}
|
||||
|
||||
params, err := decodeECBalanceTaskParams(request.Job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return fmt.Errorf("ec balance source node is required")
|
||||
}
|
||||
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
|
||||
return fmt.Errorf("ec balance target node is required")
|
||||
}
|
||||
|
||||
task := ecbalancetask.NewECBalanceTask(
|
||||
request.Job.JobId,
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
h.grpcDialOption,
|
||||
)
|
||||
|
||||
execCtx, execCancel := context.WithCancel(ctx)
|
||||
defer execCancel()
|
||||
|
||||
task.SetProgressCallback(func(progress float64, stage string) {
|
||||
message := fmt.Sprintf("ec balance progress %.0f%%", progress)
|
||||
if strings.TrimSpace(stage) != "" {
|
||||
message = stage
|
||||
}
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity(stage, message),
|
||||
},
|
||||
}); err != nil {
|
||||
glog.Warningf("EC balance job %s (%s): failed to send progress (%.0f%%, stage=%q): %v, cancelling execution",
|
||||
request.Job.JobId, request.Job.JobType, progress, stage, err)
|
||||
execCancel()
|
||||
}
|
||||
})
|
||||
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: "ec balance job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity("assigned", "ec balance job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := task.Execute(execCtx, params); err != nil {
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_FAILED,
|
||||
ProgressPercent: 100,
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
sourceNode := params.Sources[0].Node
|
||||
targetNode := params.Targets[0].Node
|
||||
resultSummary := fmt.Sprintf("EC shard balance completed: volume %d shards moved from %s to %s",
|
||||
params.VolumeId, sourceNode, targetNode)
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
Success: true,
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
|
||||
},
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
metrics, activeTopology, _, err := collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveECBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *ecBalanceWorkerConfig {
|
||||
taskConfig := ecbalancetask.NewDefaultConfig()
|
||||
|
||||
imbalanceThreshold := readDoubleConfig(values, "imbalance_threshold", taskConfig.ImbalanceThreshold)
|
||||
if imbalanceThreshold < ecBalanceMinImbalanceThreshold {
|
||||
imbalanceThreshold = ecBalanceMinImbalanceThreshold
|
||||
}
|
||||
if imbalanceThreshold > ecBalanceMaxImbalanceThreshold {
|
||||
imbalanceThreshold = ecBalanceMaxImbalanceThreshold
|
||||
}
|
||||
taskConfig.ImbalanceThreshold = imbalanceThreshold
|
||||
|
||||
minServerCountRaw := readInt64Config(values, "min_server_count", int64(taskConfig.MinServerCount))
|
||||
if minServerCountRaw < int64(ecBalanceMinServerCount) {
|
||||
minServerCountRaw = int64(ecBalanceMinServerCount)
|
||||
}
|
||||
if minServerCountRaw > math.MaxInt32 {
|
||||
minServerCountRaw = math.MaxInt32
|
||||
}
|
||||
taskConfig.MinServerCount = int(minServerCountRaw)
|
||||
|
||||
minIntervalRaw := readInt64Config(values, "min_interval_seconds", 60*60)
|
||||
if minIntervalRaw < 0 {
|
||||
minIntervalRaw = 0
|
||||
}
|
||||
if minIntervalRaw > math.MaxInt32 {
|
||||
minIntervalRaw = math.MaxInt32
|
||||
}
|
||||
minIntervalSeconds := int(minIntervalRaw)
|
||||
|
||||
taskConfig.PreferredTags = util.NormalizeTagList(readStringListConfig(values, "preferred_tags"))
|
||||
|
||||
return &ecBalanceWorkerConfig{
|
||||
TaskConfig: taskConfig,
|
||||
MinIntervalSeconds: minIntervalSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func buildECBalanceProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.JobProposal, error) {
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("task detection result is nil")
|
||||
}
|
||||
if result.TypedParams == nil {
|
||||
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
|
||||
}
|
||||
|
||||
paramsPayload, err := proto.Marshal(result.TypedParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal task params: %w", err)
|
||||
}
|
||||
|
||||
proposalID := strings.TrimSpace(result.TaskID)
|
||||
if proposalID == "" {
|
||||
proposalID = fmt.Sprintf("ec-balance-%d-%d", result.VolumeID, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Dedupe key includes volume ID, shard ID, source node, and collection
|
||||
// to distinguish moves of the same shard from different source nodes (e.g. dedup)
|
||||
dedupeKey := fmt.Sprintf("ec_balance:%d", result.VolumeID)
|
||||
if len(result.TypedParams.Sources) > 0 {
|
||||
src := result.TypedParams.Sources[0]
|
||||
if len(src.ShardIds) > 0 {
|
||||
dedupeKey = fmt.Sprintf("ec_balance:%d:%d:%s", result.VolumeID, src.ShardIds[0], src.Node)
|
||||
}
|
||||
}
|
||||
if result.Collection != "" {
|
||||
dedupeKey += ":" + result.Collection
|
||||
}
|
||||
|
||||
sourceNode := ""
|
||||
targetNode := ""
|
||||
if len(result.TypedParams.Sources) > 0 {
|
||||
sourceNode = strings.TrimSpace(result.TypedParams.Sources[0].Node)
|
||||
}
|
||||
if len(result.TypedParams.Targets) > 0 {
|
||||
targetNode = strings.TrimSpace(result.TypedParams.Targets[0].Node)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Balance EC shard of volume %d", result.VolumeID)
|
||||
if sourceNode != "" && targetNode != "" {
|
||||
summary = fmt.Sprintf("Move EC shard of volume %d: %s → %s", result.VolumeID, sourceNode, targetNode)
|
||||
}
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: proposalID,
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: "ec_balance",
|
||||
Priority: mapTaskPriority(result.Priority),
|
||||
Summary: summary,
|
||||
Detail: strings.TrimSpace(result.Reason),
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {
|
||||
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
|
||||
},
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
|
||||
},
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "ec_balance",
|
||||
"volume_id": fmt.Sprintf("%d", result.VolumeID),
|
||||
"collection": result.Collection,
|
||||
"source_node": sourceNode,
|
||||
"target_node": targetNode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeECBalanceTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
|
||||
if job == nil {
|
||||
return nil, fmt.Errorf("job spec is nil")
|
||||
}
|
||||
|
||||
// Try protobuf-encoded params first (preferred path)
|
||||
if payload := readBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err != nil {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: unmarshal task_params_pb: %w", err)
|
||||
}
|
||||
if params.TaskId == "" {
|
||||
params.TaskId = job.JobId
|
||||
}
|
||||
// Validate execution-critical fields in the deserialized TaskParams
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].Node")
|
||||
}
|
||||
if len(params.Sources[0].ShardIds) == 0 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].ShardIds")
|
||||
}
|
||||
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].Node")
|
||||
}
|
||||
if len(params.Targets[0].ShardIds) == 0 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].ShardIds")
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// Legacy fallback: construct TaskParams from individual scalar parameters.
|
||||
// All execution-critical fields are required.
|
||||
volumeID := readInt64Config(job.Parameters, "volume_id", 0)
|
||||
sourceNode := strings.TrimSpace(readStringConfig(job.Parameters, "source_server", ""))
|
||||
targetNode := strings.TrimSpace(readStringConfig(job.Parameters, "target_server", ""))
|
||||
collection := readStringConfig(job.Parameters, "collection", "")
|
||||
|
||||
if volumeID <= 0 || volumeID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid or missing volume_id: %d", volumeID)
|
||||
}
|
||||
if sourceNode == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing source_server")
|
||||
}
|
||||
if targetNode == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing target_server")
|
||||
}
|
||||
|
||||
shardIDVal, hasShardID := job.Parameters["shard_id"]
|
||||
if !hasShardID || shardIDVal == nil {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing shard_id (required for EcBalanceTaskParams)")
|
||||
}
|
||||
shardID := readInt64Config(job.Parameters, "shard_id", -1)
|
||||
if shardID < 0 || shardID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid shard_id: %d", shardID)
|
||||
}
|
||||
|
||||
sourceDiskID := readInt64Config(job.Parameters, "source_disk_id", 0)
|
||||
if sourceDiskID < 0 || sourceDiskID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid source_disk_id: %d", sourceDiskID)
|
||||
}
|
||||
targetDiskID := readInt64Config(job.Parameters, "target_disk_id", 0)
|
||||
if targetDiskID < 0 || targetDiskID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid target_disk_id: %d", targetDiskID)
|
||||
}
|
||||
|
||||
return &worker_pb.TaskParams{
|
||||
TaskId: job.JobId,
|
||||
VolumeId: uint32(volumeID),
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: sourceNode,
|
||||
DiskId: uint32(sourceDiskID),
|
||||
ShardIds: []uint32{uint32(shardID)},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: targetNode,
|
||||
DiskId: uint32(targetDiskID),
|
||||
ShardIds: []uint32{uint32(shardID)},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
TimeoutSeconds: 600,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func emitECBalanceDecisionTrace(
|
||||
sender DetectionSender,
|
||||
taskConfig *ecbalancetask.Config,
|
||||
results []*workertypes.TaskDetectionResult,
|
||||
maxResults int,
|
||||
hasMore bool,
|
||||
) error {
|
||||
if sender == nil || taskConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count moves by phase
|
||||
phaseCounts := make(map[string]int)
|
||||
for _, result := range results {
|
||||
if result.Reason != "" {
|
||||
// Extract phase from reason string
|
||||
for _, phase := range []string{"dedup", "cross_rack", "within_rack", "global"} {
|
||||
if strings.Contains(result.Reason, phase) {
|
||||
phaseCounts[phase]++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summarySuffix := ""
|
||||
if hasMore {
|
||||
summarySuffix = fmt.Sprintf(" (max_results=%d reached)", maxResults)
|
||||
}
|
||||
|
||||
summaryMessage := fmt.Sprintf(
|
||||
"EC balance detection: %d moves proposed%s (dedup=%d, cross_rack=%d, within_rack=%d, global=%d)",
|
||||
len(results),
|
||||
summarySuffix,
|
||||
phaseCounts["dedup"],
|
||||
phaseCounts["cross_rack"],
|
||||
phaseCounts["within_rack"],
|
||||
phaseCounts["global"],
|
||||
)
|
||||
|
||||
return sender.SendActivity(BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
|
||||
},
|
||||
"has_more": {
|
||||
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
|
||||
},
|
||||
"dedup_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["dedup"])},
|
||||
},
|
||||
"cross_rack_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["cross_rack"])},
|
||||
},
|
||||
"within_rack_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["within_rack"])},
|
||||
},
|
||||
"global_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["global"])},
|
||||
},
|
||||
"imbalance_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.ImbalanceThreshold},
|
||||
},
|
||||
"min_server_count": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinServerCount)},
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
ecbalancetask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/ec_balance"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDeriveECBalanceWorkerConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]*plugin_pb.ConfigValue
|
||||
check func(t *testing.T, config *ecBalanceWorkerConfig)
|
||||
}{
|
||||
{
|
||||
name: "nil values uses defaults",
|
||||
values: nil,
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.2 {
|
||||
t.Errorf("expected default threshold 0.2, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if config.TaskConfig.MinServerCount != 3 {
|
||||
t.Errorf("expected default min_server_count 3, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom threshold",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 5}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.3 {
|
||||
t.Errorf("expected threshold 0.3, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if config.TaskConfig.MinServerCount != 5 {
|
||||
t.Errorf("expected min_server_count 5, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold clamped to min",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.01}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.05 {
|
||||
t.Errorf("expected clamped threshold 0.05, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold clamped to max",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.9}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.5 {
|
||||
t.Errorf("expected clamped threshold 0.5, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "min_server_count clamped to 2",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.MinServerCount != 2 {
|
||||
t.Errorf("expected min_server_count 2, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := deriveECBalanceWorkerConfig(tt.values)
|
||||
tt.check(t, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildECBalanceProposal(t *testing.T) {
|
||||
result := &workertypes.TaskDetectionResult{
|
||||
TaskID: "test-task-123",
|
||||
TaskType: workertypes.TaskTypeECBalance,
|
||||
VolumeID: 42,
|
||||
Collection: "test-col",
|
||||
Priority: workertypes.TaskPriorityMedium,
|
||||
Reason: "cross_rack balance",
|
||||
TypedParams: &worker_pb.TaskParams{
|
||||
VolumeId: 42,
|
||||
Collection: "test-col",
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: "source:8080",
|
||||
ShardIds: []uint32{5},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: "target:8080",
|
||||
ShardIds: []uint32{5},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
proposal, err := buildECBalanceProposal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if proposal.JobType != "ec_balance" {
|
||||
t.Errorf("expected job type ec_balance, got %s", proposal.JobType)
|
||||
}
|
||||
if proposal.ProposalId != "test-task-123" {
|
||||
t.Errorf("expected proposal ID test-task-123, got %s", proposal.ProposalId)
|
||||
}
|
||||
if proposal.DedupeKey != "ec_balance:42:5:source:8080:test-col" {
|
||||
t.Errorf("expected dedupe key ec_balance:42:5:source:8080:test-col, got %s", proposal.DedupeKey)
|
||||
}
|
||||
if proposal.Labels["source_node"] != "source:8080" {
|
||||
t.Errorf("expected source_node label source:8080, got %s", proposal.Labels["source_node"])
|
||||
}
|
||||
if proposal.Labels["target_node"] != "target:8080" {
|
||||
t.Errorf("expected target_node label target:8080, got %s", proposal.Labels["target_node"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildECBalanceProposalNilResult(t *testing.T) {
|
||||
_, err := buildECBalanceProposal(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsFromProtobuf(t *testing.T) {
|
||||
originalParams := &worker_pb.TaskParams{
|
||||
TaskId: "test-id",
|
||||
VolumeId: 100,
|
||||
Collection: "test-col",
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: "source:8080",
|
||||
ShardIds: []uint32{3},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: "target:8080",
|
||||
ShardIds: []uint32{3},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
DiskType: "hdd",
|
||||
TimeoutSeconds: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := proto.Marshal(originalParams)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-1",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
decoded, err := decodeECBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
|
||||
if decoded.VolumeId != 100 {
|
||||
t.Errorf("expected volume_id 100, got %d", decoded.VolumeId)
|
||||
}
|
||||
if decoded.Collection != "test-col" {
|
||||
t.Errorf("expected collection test-col, got %s", decoded.Collection)
|
||||
}
|
||||
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "source:8080" {
|
||||
t.Error("source mismatch")
|
||||
}
|
||||
if len(decoded.Targets) != 1 || decoded.Targets[0].Node != "target:8080" {
|
||||
t.Error("target mismatch")
|
||||
}
|
||||
ecParams := decoded.GetEcBalanceParams()
|
||||
if ecParams == nil {
|
||||
t.Fatal("expected ec_balance_params")
|
||||
}
|
||||
if ecParams.DiskType != "hdd" {
|
||||
t.Errorf("expected disk_type hdd, got %s", ecParams.DiskType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsFallback(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-2",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 200}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "fallback-col"}},
|
||||
"shard_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
|
||||
"source_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
"target_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
}
|
||||
|
||||
decoded, err := decodeECBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode fallback: %v", err)
|
||||
}
|
||||
|
||||
if decoded.VolumeId != 200 {
|
||||
t.Errorf("expected volume_id 200, got %d", decoded.VolumeId)
|
||||
}
|
||||
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "src:8080" {
|
||||
t.Error("source mismatch")
|
||||
}
|
||||
if decoded.Sources[0].ShardIds[0] != 7 {
|
||||
t.Errorf("expected shard_id 7, got %d", decoded.Sources[0].ShardIds[0])
|
||||
}
|
||||
if decoded.Sources[0].DiskId != 1 {
|
||||
t.Errorf("expected source disk_id 1, got %d", decoded.Sources[0].DiskId)
|
||||
}
|
||||
if decoded.Targets[0].DiskId != 2 {
|
||||
t.Errorf("expected target disk_id 2, got %d", decoded.Targets[0].DiskId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsProtobufValidation(t *testing.T) {
|
||||
// Protobuf payload with missing ShardIds should fail validation
|
||||
badParams := &worker_pb.TaskParams{
|
||||
TaskId: "test-id",
|
||||
VolumeId: 100,
|
||||
Sources: []*worker_pb.TaskSource{{Node: "source:8080"}}, // no ShardIds
|
||||
Targets: []*worker_pb.TaskTarget{{Node: "target:8080", ShardIds: []uint32{3}}},
|
||||
}
|
||||
payload, _ := proto.Marshal(badParams)
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-validate",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
_, err := decodeECBalanceTaskParams(job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing Sources[0].ShardIds in protobuf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsMissingShardID(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-3",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 300}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "test-col"}},
|
||||
// shard_id deliberately missing
|
||||
},
|
||||
}
|
||||
|
||||
_, err := decodeECBalanceTaskParams(job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing shard_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBalanceHandlerCapability(t *testing.T) {
|
||||
handler := NewECBalanceHandler(nil)
|
||||
cap := handler.Capability()
|
||||
|
||||
if cap.JobType != "ec_balance" {
|
||||
t.Errorf("expected job type ec_balance, got %s", cap.JobType)
|
||||
}
|
||||
if !cap.CanDetect {
|
||||
t.Error("expected CanDetect=true")
|
||||
}
|
||||
if !cap.CanExecute {
|
||||
t.Error("expected CanExecute=true")
|
||||
}
|
||||
if cap.Weight != 60 {
|
||||
t.Errorf("expected weight 60, got %d", cap.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBalanceConfigRoundTrip(t *testing.T) {
|
||||
config := ecbalancetask.NewDefaultConfig()
|
||||
config.ImbalanceThreshold = 0.3
|
||||
config.MinServerCount = 5
|
||||
config.CollectionFilter = "my_col"
|
||||
config.DiskType = "ssd"
|
||||
config.PreferredTags = []string{"fast", "ssd"}
|
||||
|
||||
policy := config.ToTaskPolicy()
|
||||
if policy == nil {
|
||||
t.Fatal("expected non-nil policy")
|
||||
}
|
||||
|
||||
config2 := ecbalancetask.NewDefaultConfig()
|
||||
if err := config2.FromTaskPolicy(policy); err != nil {
|
||||
t.Fatalf("failed to load from policy: %v", err)
|
||||
}
|
||||
|
||||
if config2.ImbalanceThreshold != 0.3 {
|
||||
t.Errorf("expected threshold 0.3, got %f", config2.ImbalanceThreshold)
|
||||
}
|
||||
if config2.MinServerCount != 5 {
|
||||
t.Errorf("expected min_server_count 5, got %d", config2.MinServerCount)
|
||||
}
|
||||
if config2.CollectionFilter != "my_col" {
|
||||
t.Errorf("expected collection_filter my_col, got %s", config2.CollectionFilter)
|
||||
}
|
||||
if config2.DiskType != "ssd" {
|
||||
t.Errorf("expected disk_type ssd, got %s", config2.DiskType)
|
||||
}
|
||||
if len(config2.PreferredTags) != 2 || config2.PreferredTags[0] != "fast" {
|
||||
t.Errorf("expected preferred_tags [fast ssd], got %v", config2.PreferredTags)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
erasurecodingtask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
@@ -601,7 +601,8 @@ func (h *ErasureCodingHandler) collectVolumeMetrics(
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
return collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
metrics, activeTopology, _, err := collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveErasureCodingWorkerConfig(values map[string]*plugin_pb.ConfigValue) *erasureCodingWorkerConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,22 @@ const (
|
||||
defaultMaxSnapshotsToKeep = 5
|
||||
defaultOrphanOlderThanHours = 72
|
||||
defaultMaxCommitRetries = 5
|
||||
defaultTargetFileSizeBytes = 256 * 1024 * 1024
|
||||
defaultTargetFileSizeMB = 256
|
||||
defaultMinInputFiles = 5
|
||||
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"
|
||||
MetricEntriesTotal = "entries_total"
|
||||
MetricDurationMs = "duration_ms"
|
||||
)
|
||||
|
||||
// Config holds parsed worker config values.
|
||||
@@ -29,7 +42,9 @@ type Config struct {
|
||||
MaxCommitRetries int64
|
||||
TargetFileSizeBytes int64
|
||||
MinInputFiles int64
|
||||
MinManifestsToRewrite int64
|
||||
Operations string
|
||||
ApplyDeletes bool
|
||||
}
|
||||
|
||||
// ParseConfig extracts an iceberg maintenance Config from plugin config values.
|
||||
@@ -40,9 +55,11 @@ func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
||||
MaxSnapshotsToKeep: readInt64Config(values, "max_snapshots_to_keep", defaultMaxSnapshotsToKeep),
|
||||
OrphanOlderThanHours: readInt64Config(values, "orphan_older_than_hours", defaultOrphanOlderThanHours),
|
||||
MaxCommitRetries: readInt64Config(values, "max_commit_retries", defaultMaxCommitRetries),
|
||||
TargetFileSizeBytes: readInt64Config(values, "target_file_size_bytes", defaultTargetFileSizeBytes),
|
||||
TargetFileSizeBytes: readInt64Config(values, "target_file_size_mb", defaultTargetFileSizeMB) * 1024 * 1024,
|
||||
MinInputFiles: readInt64Config(values, "min_input_files", defaultMinInputFiles),
|
||||
MinManifestsToRewrite: readInt64Config(values, "min_manifests_to_rewrite", defaultMinManifestsToRewrite),
|
||||
Operations: readStringConfig(values, "operations", defaultOperations),
|
||||
ApplyDeletes: readBoolConfig(values, "apply_deletes", true),
|
||||
}
|
||||
|
||||
// Clamp to safe minimums using the default constants
|
||||
@@ -59,11 +76,14 @@ func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
||||
cfg.MaxCommitRetries = defaultMaxCommitRetries
|
||||
}
|
||||
if cfg.TargetFileSizeBytes <= 0 {
|
||||
cfg.TargetFileSizeBytes = defaultTargetFileSizeBytes
|
||||
cfg.TargetFileSizeBytes = defaultTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
if cfg.MinInputFiles < 2 {
|
||||
cfg.MinInputFiles = defaultMinInputFiles
|
||||
}
|
||||
if cfg.MinManifestsToRewrite < minManifestsToRewrite {
|
||||
cfg.MinManifestsToRewrite = minManifestsToRewrite
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -147,6 +167,35 @@ func readStringConfig(values map[string]*plugin_pb.ConfigValue, field string, fa
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"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"
|
||||
@@ -18,11 +20,12 @@ import (
|
||||
|
||||
// tableInfo captures metadata about a table for detection/execution.
|
||||
type tableInfo struct {
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
TablePath string // namespace/tableName
|
||||
Metadata table.Metadata
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
TablePath string // namespace/tableName
|
||||
MetadataFileName string
|
||||
Metadata table.Metadata
|
||||
}
|
||||
|
||||
// scanTablesForMaintenance enumerates table buckets and their tables,
|
||||
@@ -37,13 +40,16 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
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)
|
||||
|
||||
// List entries under /buckets to find table buckets
|
||||
bucketsPath := s3tables.TablesPath
|
||||
bucketEntries, err := listFilerEntries(ctx, filerClient, bucketsPath, "")
|
||||
if err != nil {
|
||||
@@ -117,7 +123,8 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
|
||||
// Parse the internal metadata to get FullMetadata
|
||||
var internalMeta struct {
|
||||
Metadata *struct {
|
||||
MetadataLocation string `json:"metadataLocation,omitempty"`
|
||||
Metadata *struct {
|
||||
FullMetadata json.RawMessage `json:"fullMetadata,omitempty"`
|
||||
} `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -135,13 +142,21 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
continue
|
||||
}
|
||||
|
||||
if needsMaintenance(icebergMeta, config) {
|
||||
tablePath := path.Join(nsName, tblName)
|
||||
metadataFileName := metadataFileNameFromLocation(internalMeta.MetadataLocation, bucketName, tablePath)
|
||||
needsWork, err := h.tableNeedsMaintenance(ctx, filerClient, bucketName, tablePath, icebergMeta, metadataFileName, config, ops)
|
||||
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: path.Join(nsName, tblName),
|
||||
Metadata: icebergMeta,
|
||||
BucketName: bucketName,
|
||||
Namespace: nsName,
|
||||
TableName: tblName,
|
||||
TablePath: tablePath,
|
||||
MetadataFileName: metadataFileName,
|
||||
Metadata: icebergMeta,
|
||||
})
|
||||
if limit > 0 && len(tables) > limit {
|
||||
return tables, nil
|
||||
@@ -154,8 +169,211 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// needsMaintenance checks if a table needs any maintenance based on
|
||||
// metadata-only thresholds (no manifest reading).
|
||||
func normalizeDetectionConfig(config Config) Config {
|
||||
normalized := config
|
||||
if normalized.TargetFileSizeBytes <= 0 {
|
||||
normalized.TargetFileSizeBytes = defaultTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
if normalized.MinInputFiles < 2 {
|
||||
normalized.MinInputFiles = defaultMinInputFiles
|
||||
}
|
||||
if normalized.MinManifestsToRewrite < minManifestsToRewrite {
|
||||
normalized.MinManifestsToRewrite = minManifestsToRewrite
|
||||
}
|
||||
if normalized.OrphanOlderThanHours <= 0 {
|
||||
normalized.OrphanOlderThanHours = defaultOrphanOlderThanHours
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (h *Handler) tableNeedsMaintenance(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
meta table.Metadata,
|
||||
metadataFileName string,
|
||||
config Config,
|
||||
ops []string,
|
||||
) (bool, error) {
|
||||
config = normalizeDetectionConfig(config)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
loadManifests := func() ([]iceberg.ManifestFile, error) {
|
||||
return loadCurrentManifests(ctx, filerClient, bucketName, tablePath, meta)
|
||||
}
|
||||
|
||||
var currentManifests []iceberg.ManifestFile
|
||||
var manifestsErr error
|
||||
manifestsLoaded := false
|
||||
getCurrentManifests := func() ([]iceberg.ManifestFile, error) {
|
||||
if manifestsLoaded {
|
||||
return currentManifests, manifestsErr
|
||||
}
|
||||
currentManifests, manifestsErr = loadManifests()
|
||||
manifestsLoaded = true
|
||||
return currentManifests, manifestsErr
|
||||
}
|
||||
var opEvalErrors []string
|
||||
|
||||
for _, op := range ops {
|
||||
switch op {
|
||||
case "expire_snapshots":
|
||||
// Handled by the metadata-only check above.
|
||||
continue
|
||||
case "compact":
|
||||
manifests, err := getCurrentManifests()
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
eligible, err := hasEligibleCompaction(ctx, filerClient, bucketName, tablePath, manifests, config)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if eligible {
|
||||
return true, nil
|
||||
}
|
||||
case "rewrite_manifests":
|
||||
manifests, err := getCurrentManifests()
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if countDataManifests(manifests) >= config.MinManifestsToRewrite {
|
||||
return true, nil
|
||||
}
|
||||
case "remove_orphans":
|
||||
if metadataFileName == "" {
|
||||
_, currentMetadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
metadataFileName = currentMetadataFileName
|
||||
}
|
||||
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, tablePath, 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, bucketName, tablePath string) string {
|
||||
if location == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Base(normalizeIcebergPath(location, bucketName, tablePath))
|
||||
}
|
||||
|
||||
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, tablePath string,
|
||||
meta table.Metadata,
|
||||
) ([]iceberg.ManifestFile, error) {
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(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, tablePath string,
|
||||
manifests []iceberg.ManifestFile,
|
||||
config Config,
|
||||
) (bool, error) {
|
||||
if len(manifests) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
minInputFiles, err := compactionMinInputFiles(config.MinInputFiles)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var dataManifests []iceberg.ManifestFile
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
dataManifests = append(dataManifests, mf)
|
||||
}
|
||||
if len(dataManifests) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var allEntries []iceberg.ManifestEntry
|
||||
for _, mf := range dataManifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
allEntries = append(allEntries, entries...)
|
||||
}
|
||||
|
||||
bins := buildCompactionBins(allEntries, config.TargetFileSizeBytes, minInputFiles)
|
||||
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.
|
||||
func needsMaintenance(meta table.Metadata, config Config) bool {
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,17 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
@@ -25,6 +29,30 @@ type filerFileEntry struct {
|
||||
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
|
||||
@@ -174,15 +202,20 @@ func loadFileByIcebergPath(ctx context.Context, client filer_pb.SeaweedFilerClie
|
||||
return nil, fmt.Errorf("file not found: %s/%s", dir, fileName)
|
||||
}
|
||||
|
||||
// Inline content is available for small files (metadata, manifests, and
|
||||
// manifest lists written by saveFilerFile). Larger files uploaded via S3
|
||||
// are stored as chunks with empty Content — detect this and return a
|
||||
// clear error rather than silently returning empty data.
|
||||
if len(resp.Entry.Content) == 0 && len(resp.Entry.Chunks) > 0 {
|
||||
return nil, fmt.Errorf("file %s/%s is stored in chunks; only inline content is supported", dir, fileName)
|
||||
if len(resp.Entry.Content) > 0 || len(resp.Entry.Chunks) == 0 {
|
||||
return resp.Entry.Content, nil
|
||||
}
|
||||
|
||||
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 (which may be an S3 URL, an
|
||||
@@ -248,9 +281,10 @@ func deleteFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, di
|
||||
}
|
||||
|
||||
// updateTableMetadataXattr updates the table entry's metadata xattr with
|
||||
// the new Iceberg metadata. It performs a compare-and-swap: if the stored
|
||||
// metadataVersion does not match expectedVersion, it returns
|
||||
// errMetadataVersionConflict so the caller can retry.
|
||||
// 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 table-relative path to the new metadata file
|
||||
// (e.g. "metadata/v3.metadata.json").
|
||||
func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerClient, tableDir string, expectedVersion int, newFullMetadata []byte, newMetadataLocation string) error {
|
||||
@@ -279,13 +313,8 @@ func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerC
|
||||
return fmt.Errorf("unmarshal existing xattr: %w", err)
|
||||
}
|
||||
|
||||
// Compare-and-swap: verify the stored metadataVersion matches what we expect.
|
||||
// NOTE: This is a client-side CAS — two workers could both read the same
|
||||
// version, pass this check, and race at UpdateEntry (last-write-wins).
|
||||
// The proper fix is server-side precondition support on UpdateEntryRequest
|
||||
// (e.g. expect-version or If-Match semantics). Until then, commitWithRetry
|
||||
// with exponential backoff mitigates but does not eliminate the race.
|
||||
// Avoid scheduling concurrent maintenance on the same table.
|
||||
// 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)
|
||||
@@ -336,17 +365,29 @@ func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerC
|
||||
return fmt.Errorf("marshal updated xattr: %w", err)
|
||||
}
|
||||
|
||||
expectedVersionXattr := resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion]
|
||||
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: map[string][]byte{
|
||||
s3tables.ExtendedKeyMetadataVersion: expectedVersionXattr,
|
||||
},
|
||||
})
|
||||
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 {
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"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"
|
||||
@@ -31,6 +33,8 @@ 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}
|
||||
@@ -133,12 +137,12 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
Description: "Controls for bin-packing small Parquet data files.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "target_file_size_bytes",
|
||||
Label: "Target File Size (bytes)",
|
||||
Description: "Files smaller than this are candidates for compaction.",
|
||||
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: 1024 * 1024}},
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "min_input_files",
|
||||
@@ -148,6 +152,13 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -165,6 +176,21 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -190,13 +216,15 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_bytes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeBytes}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"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}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
@@ -211,13 +239,14 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
JobTypeMaxRuntimeSeconds: 3600, // 1 hour max
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_bytes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeBytes}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"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}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -255,11 +284,10 @@ func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionReq
|
||||
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
|
||||
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
|
||||
|
||||
// Connect to filer to scan table buckets
|
||||
filerAddress := filerAddresses[0]
|
||||
conn, err := grpc.NewClient(filerAddress, h.grpcDialOption)
|
||||
// Connect to filer — try each address until one succeeds.
|
||||
filerAddress, conn, err := h.connectToFiler(ctx, filerAddresses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerAddress, err)
|
||||
return fmt.Errorf("connect to filer: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||
@@ -329,6 +357,12 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
if bucketName == "" || namespace == "" || tableName == "" || filerAddress == "" {
|
||||
return fmt.Errorf("missing required parameters: bucket_name=%q, namespace=%q, table_name=%q, filer_address=%q", bucketName, namespace, tableName, filerAddress)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
@@ -361,7 +395,7 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
}
|
||||
|
||||
// Connect to filer
|
||||
conn, err := grpc.NewClient(filerAddress, h.grpcDialOption)
|
||||
conn, err := h.dialFiler(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerAddress, err)
|
||||
}
|
||||
@@ -372,6 +406,7 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
var lastErr error
|
||||
totalOps := len(ops)
|
||||
completedOps := 0
|
||||
allMetrics := make(map[string]int64)
|
||||
|
||||
// Execute operations in correct Iceberg maintenance order:
|
||||
// expire_snapshots → remove_orphans → rewrite_manifests
|
||||
@@ -399,21 +434,37 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
|
||||
var opResult string
|
||||
var opErr error
|
||||
var opMetrics map[string]int64
|
||||
|
||||
switch op {
|
||||
case "compact":
|
||||
opResult, opErr = h.compactDataFiles(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
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 "expire_snapshots":
|
||||
opResult, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
opResult, opMetrics, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "remove_orphans":
|
||||
opResult, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
opResult, opMetrics, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "rewrite_manifests":
|
||||
opResult, opErr = h.rewriteManifests(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
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++
|
||||
if opErr != nil {
|
||||
glog.Warningf("iceberg maintenance %s failed for %s/%s/%s: %v", op, bucketName, namespace, tableName, opErr)
|
||||
@@ -427,6 +478,16 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
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,
|
||||
@@ -438,12 +499,8 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
return ""
|
||||
}(),
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
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}},
|
||||
},
|
||||
Summary: resultSummary,
|
||||
OutputValues: outputValues,
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
@@ -467,5 +524,41 @@ func (h *Handler) sendEmptyDetection(sender pluginworker.DetectionSender) error
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) dialFiler(ctx context.Context, address string) (*grpc.ClientConn, error) {
|
||||
opCtx, opCancel := context.WithTimeout(ctx, filerConnectTimeout)
|
||||
defer opCancel()
|
||||
|
||||
conn, err := pb.GrpcDial(opCtx, address, 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 address in order and returns the first
|
||||
// address whose gRPC connection and Ping request succeed.
|
||||
func (h *Handler) connectToFiler(ctx context.Context, addresses []string) (string, *grpc.ClientConn, error) {
|
||||
var lastErr error
|
||||
for _, addr := range addresses {
|
||||
conn, err := h.dialFiler(ctx, addr)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("filer %s: %w", addr, err)
|
||||
continue
|
||||
}
|
||||
return addr, conn, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no filer addresses provided")
|
||||
}
|
||||
return "", nil, lastErr
|
||||
}
|
||||
|
||||
// Ensure Handler implements JobHandler.
|
||||
var _ pluginworker.JobHandler = (*Handler)(nil)
|
||||
|
||||
@@ -2,13 +2,18 @@ package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
@@ -363,11 +368,11 @@ func TestManifestRewriteNestedPathConsistency(t *testing.T) {
|
||||
|
||||
func TestNormalizeIcebergPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
icebergPath string
|
||||
bucket string
|
||||
tablePath string
|
||||
expected string
|
||||
bucket string
|
||||
tablePath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"relative metadata path",
|
||||
@@ -500,13 +505,19 @@ func TestBuildCompactionBinsMultiplePartitions(t *testing.T) {
|
||||
|
||||
partA := map[int]any{1: "us-east"}
|
||||
partB := map[int]any{1: "eu-west"}
|
||||
partitionSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
|
||||
SourceID: 1,
|
||||
FieldID: 1000,
|
||||
Name: "region",
|
||||
Transform: iceberg.IdentityTransform{},
|
||||
})
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/a1.parquet", size: 1024, partition: partA},
|
||||
{path: "data/a2.parquet", size: 2048, partition: partA},
|
||||
{path: "data/b1.parquet", size: 1024, partition: partB},
|
||||
{path: "data/b2.parquet", size: 2048, partition: partB},
|
||||
{path: "data/b3.parquet", size: 4096, partition: partB},
|
||||
{path: "data/a1.parquet", size: 1024, partition: partA, partitionSpec: &partitionSpec},
|
||||
{path: "data/a2.parquet", size: 2048, partition: partA, partitionSpec: &partitionSpec},
|
||||
{path: "data/b1.parquet", size: 1024, partition: partB, partitionSpec: &partitionSpec},
|
||||
{path: "data/b2.parquet", size: 2048, partition: partB, partitionSpec: &partitionSpec},
|
||||
{path: "data/b3.parquet", size: 4096, partition: partB, partitionSpec: &partitionSpec},
|
||||
})
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
@@ -515,28 +526,142 @@ func TestBuildCompactionBinsMultiplePartitions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type testEntrySpec struct {
|
||||
path string
|
||||
size int64
|
||||
partition map[int]any
|
||||
func TestSplitOversizedBinRespectsTargetSize(t *testing.T) {
|
||||
targetSize := int64(100)
|
||||
minFiles := 2
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 80, partition: map[int]any{}},
|
||||
{path: "data/f2.parquet", size: 80, partition: map[int]any{}},
|
||||
{path: "data/f3.parquet", size: 10, partition: map[int]any{}},
|
||||
{path: "data/f4.parquet", size: 10, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := splitOversizedBin(compactionBin{
|
||||
PartitionKey: "__unpartitioned__",
|
||||
Partition: map[int]any{},
|
||||
Entries: entries,
|
||||
TotalSize: 180,
|
||||
}, targetSize, minFiles)
|
||||
|
||||
if len(bins) == 0 {
|
||||
t.Fatal("expected at least one valid bin")
|
||||
}
|
||||
for i, bin := range bins {
|
||||
if bin.TotalSize > targetSize {
|
||||
t.Fatalf("bin %d exceeds target size: got %d want <= %d", i, bin.TotalSize, targetSize)
|
||||
}
|
||||
if len(bin.Entries) < minFiles {
|
||||
t.Fatalf("bin %d does not meet minFiles: got %d want >= %d", i, len(bin.Entries), minFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntry {
|
||||
func TestSplitOversizedBinDropsImpossibleRunts(t *testing.T) {
|
||||
targetSize := int64(100)
|
||||
minFiles := 2
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 60, partition: map[int]any{}},
|
||||
{path: "data/f2.parquet", size: 60, partition: map[int]any{}},
|
||||
{path: "data/f3.parquet", size: 60, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := splitOversizedBin(compactionBin{
|
||||
PartitionKey: "__unpartitioned__",
|
||||
Partition: map[int]any{},
|
||||
Entries: entries,
|
||||
TotalSize: 180,
|
||||
}, targetSize, minFiles)
|
||||
|
||||
if len(bins) != 0 {
|
||||
t.Fatalf("expected no valid bins, got %d", len(bins))
|
||||
}
|
||||
}
|
||||
|
||||
type testEntrySpec struct {
|
||||
path string
|
||||
size int64
|
||||
partition map[int]any
|
||||
partitionSpec *iceberg.PartitionSpec
|
||||
specID int32 // partition spec ID; 0 uses UnpartitionedSpec
|
||||
}
|
||||
|
||||
func buildTestDataFile(t *testing.T, spec testEntrySpec) iceberg.DataFile {
|
||||
t.Helper()
|
||||
|
||||
partitionSpec := iceberg.UnpartitionedSpec
|
||||
if spec.partitionSpec != nil {
|
||||
partitionSpec = spec.partitionSpec
|
||||
} else if len(spec.partition) > 0 {
|
||||
t.Fatalf("partition spec is required for partitioned test entry %s", spec.path)
|
||||
}
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
*partitionSpec,
|
||||
iceberg.EntryContentData,
|
||||
spec.path,
|
||||
iceberg.ParquetFile,
|
||||
spec.partition,
|
||||
nil, nil,
|
||||
1, // recordCount (must be > 0)
|
||||
spec.size,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file %s: %v", spec.path, err)
|
||||
}
|
||||
return dfBuilder.Build()
|
||||
}
|
||||
|
||||
func makeManifestEntriesWithSnapshot(
|
||||
t *testing.T,
|
||||
specs []testEntrySpec,
|
||||
snapshotID int64,
|
||||
status iceberg.ManifestEntryStatus,
|
||||
) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
*iceberg.UnpartitionedSpec,
|
||||
iceberg.EntryContentData,
|
||||
spec.path,
|
||||
iceberg.ParquetFile,
|
||||
spec.partition,
|
||||
entries = append(entries, iceberg.NewManifestEntry(
|
||||
status,
|
||||
&snapshotID,
|
||||
nil, nil,
|
||||
1, // recordCount (must be > 0)
|
||||
spec.size,
|
||||
buildTestDataFile(t, spec),
|
||||
))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// makeTestEntries creates manifest entries using the default unpartitioned
|
||||
// spec. For multi-spec testing, use makeTestEntriesWithSpec instead.
|
||||
func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
return makeManifestEntriesWithSnapshot(t, specs, 1, iceberg.EntryStatusADDED)
|
||||
}
|
||||
|
||||
// makeTestEntriesWithSpec creates manifest entries using specific partition specs.
|
||||
// Each spec in the specs slice can specify a specID; the entry is built using
|
||||
// a PartitionSpec with that ID.
|
||||
func makeTestEntriesWithSpec(t *testing.T, specs []testEntrySpec, partSpecs map[int32]iceberg.PartitionSpec) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
ps, ok := partSpecs[s.specID]
|
||||
if !ok {
|
||||
t.Fatalf("spec ID %d not found in partSpecs map", s.specID)
|
||||
}
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
ps,
|
||||
iceberg.EntryContentData,
|
||||
s.path,
|
||||
iceberg.ParquetFile,
|
||||
s.partition,
|
||||
nil, nil,
|
||||
1,
|
||||
s.size,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file %s: %v", spec.path, err)
|
||||
t.Fatalf("failed to build data file %s: %v", s.path, err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
@@ -545,6 +670,440 @@ func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntr
|
||||
return entries
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsMultipleSpecs(t *testing.T) {
|
||||
targetSize := int64(256 * 1024 * 1024)
|
||||
minFiles := 2
|
||||
|
||||
// Two partition specs with different IDs
|
||||
spec0 := iceberg.NewPartitionSpecID(0)
|
||||
spec1 := iceberg.NewPartitionSpecID(1)
|
||||
partSpecs := map[int32]iceberg.PartitionSpec{
|
||||
0: spec0,
|
||||
1: spec1,
|
||||
}
|
||||
|
||||
// Entries from two different specs with same partition values should go
|
||||
// to separate bins.
|
||||
entries := makeTestEntriesWithSpec(t, []testEntrySpec{
|
||||
{path: "data/s0-f1.parquet", size: 1024, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/s0-f2.parquet", size: 2048, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/s1-f1.parquet", size: 1024, partition: map[int]any{}, specID: 1},
|
||||
{path: "data/s1-f2.parquet", size: 2048, partition: map[int]any{}, specID: 1},
|
||||
}, partSpecs)
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 2 {
|
||||
t.Fatalf("expected 2 bins (one per spec), got %d", len(bins))
|
||||
}
|
||||
|
||||
// Verify each bin has entries from only one spec
|
||||
specsSeen := make(map[int32]bool)
|
||||
for _, bin := range bins {
|
||||
specsSeen[bin.SpecID] = true
|
||||
for _, entry := range bin.Entries {
|
||||
if entry.DataFile().SpecID() != bin.SpecID {
|
||||
t.Errorf("bin specID=%d contains entry with specID=%d", bin.SpecID, entry.DataFile().SpecID())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !specsSeen[0] || !specsSeen[1] {
|
||||
t.Errorf("expected bins for spec 0 and 1, got specs %v", specsSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsSingleSpec(t *testing.T) {
|
||||
// Verify existing behavior: all entries with spec 0 still group correctly.
|
||||
targetSize := int64(256 * 1024 * 1024)
|
||||
minFiles := 2
|
||||
|
||||
spec0 := iceberg.NewPartitionSpecID(0)
|
||||
partSpecs := map[int32]iceberg.PartitionSpec{0: spec0}
|
||||
|
||||
entries := makeTestEntriesWithSpec(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 1024, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/f2.parquet", size: 2048, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/f3.parquet", size: 4096, partition: map[int]any{}, specID: 0},
|
||||
}, partSpecs)
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 1 {
|
||||
t.Fatalf("expected 1 bin, got %d", len(bins))
|
||||
}
|
||||
if len(bins[0].Entries) != 3 {
|
||||
t.Errorf("expected 3 entries in bin, got %d", len(bins[0].Entries))
|
||||
}
|
||||
if bins[0].SpecID != 0 {
|
||||
t.Errorf("expected specID=0, got %d", bins[0].SpecID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigApplyDeletes(t *testing.T) {
|
||||
// Default: true
|
||||
config := ParseConfig(nil)
|
||||
if !config.ApplyDeletes {
|
||||
t.Error("expected ApplyDeletes=true by default")
|
||||
}
|
||||
|
||||
// Explicit false
|
||||
config = ParseConfig(map[string]*plugin_pb.ConfigValue{
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: false}},
|
||||
})
|
||||
if config.ApplyDeletes {
|
||||
t.Error("expected ApplyDeletes=false when explicitly set")
|
||||
}
|
||||
|
||||
// String "false"
|
||||
config = ParseConfig(map[string]*plugin_pb.ConfigValue{
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "false"}},
|
||||
})
|
||||
if config.ApplyDeletes {
|
||||
t.Error("expected ApplyDeletes=false when set via string 'false'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPositionDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
// Create a position delete Parquet file with file_path + pos columns
|
||||
type posDeleteRow struct {
|
||||
FilePath string `parquet:"file_path"`
|
||||
Pos int64 `parquet:"pos"`
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewWriter(&buf, parquet.SchemaOf(new(posDeleteRow)))
|
||||
rows := []posDeleteRow{
|
||||
{"data/file1.parquet", 0},
|
||||
{"data/file1.parquet", 5},
|
||||
{"data/file1.parquet", 10},
|
||||
{"data/file2.parquet", 3},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := writer.Write(&r); err != nil {
|
||||
t.Fatalf("write pos delete row: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close pos delete writer: %v", err)
|
||||
}
|
||||
|
||||
// Store in fake filer
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, "pos-delete-1.parquet", &filer_pb.Entry{
|
||||
Name: "pos-delete-1.parquet",
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
|
||||
// Create a manifest entry for the position delete file
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentPosDeletes,
|
||||
"data/pos-delete-1.parquet",
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
int64(len(rows)),
|
||||
int64(buf.Len()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build pos delete data file: %v", err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
|
||||
result, err := collectPositionDeletes(context.Background(), client, "test-bucket", "ns/tbl", []iceberg.ManifestEntry{entry})
|
||||
if err != nil {
|
||||
t.Fatalf("collectPositionDeletes: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(result["data/file1.parquet"]) != 3 {
|
||||
t.Errorf("expected 3 positions for file1, got %d", len(result["data/file1.parquet"]))
|
||||
}
|
||||
if len(result["data/file2.parquet"]) != 1 {
|
||||
t.Errorf("expected 1 position for file2, got %d", len(result["data/file2.parquet"]))
|
||||
}
|
||||
|
||||
// Verify sorted
|
||||
positions := result["data/file1.parquet"]
|
||||
for i := 1; i < len(positions); i++ {
|
||||
if positions[i] <= positions[i-1] {
|
||||
t.Errorf("positions not sorted: %v", positions)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectEqualityDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
// Create an equality delete Parquet file with id + name columns
|
||||
type eqDeleteRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewWriter(&buf, parquet.SchemaOf(new(eqDeleteRow)))
|
||||
deleteRows := []eqDeleteRow{
|
||||
{1, "alice"},
|
||||
{2, "bob"},
|
||||
{3, "charlie"},
|
||||
}
|
||||
for _, r := range deleteRows {
|
||||
if err := writer.Write(&r); err != nil {
|
||||
t.Fatalf("write eq delete row: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close eq delete writer: %v", err)
|
||||
}
|
||||
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, "eq-delete-1.parquet", &filer_pb.Entry{
|
||||
Name: "eq-delete-1.parquet",
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
|
||||
// Create manifest entry with equality field IDs
|
||||
schema := newTestSchema() // has field 1=id, 2=name
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentEqDeletes,
|
||||
"data/eq-delete-1.parquet",
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
int64(len(deleteRows)),
|
||||
int64(buf.Len()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build eq delete data file: %v", err)
|
||||
}
|
||||
dfBuilder.EqualityFieldIDs([]int{1, 2}) // id + name
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
|
||||
groups, err := collectEqualityDeletes(context.Background(), client, "test-bucket", "ns/tbl", []iceberg.ManifestEntry{entry}, schema)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEqualityDeletes: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("expected 1 equality delete group, got %d", len(groups))
|
||||
}
|
||||
if len(groups[0].Keys) != 3 {
|
||||
t.Errorf("expected 3 equality delete keys, got %d", len(groups[0].Keys))
|
||||
}
|
||||
if len(groups[0].FieldIDs) != 2 {
|
||||
t.Errorf("expected 2 field IDs, got %d", len(groups[0].FieldIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeParquetFilesWithPositionDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
// Create two data files with known rows
|
||||
type dataRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
|
||||
writeDataFile := func(name string, rows []dataRow) {
|
||||
var buf bytes.Buffer
|
||||
w := parquet.NewWriter(&buf, parquet.SchemaOf(new(dataRow)))
|
||||
for _, r := range rows {
|
||||
if err := w.Write(&r); err != nil {
|
||||
t.Fatalf("write row: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, name, &filer_pb.Entry{
|
||||
Name: name,
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
writeDataFile("file1.parquet", []dataRow{
|
||||
{1, "alice"}, {2, "bob"}, {3, "charlie"}, {4, "dave"}, {5, "eve"},
|
||||
})
|
||||
writeDataFile("file2.parquet", []dataRow{
|
||||
{6, "frank"}, {7, "grace"},
|
||||
})
|
||||
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
makeEntry := func(path string, size int64) iceberg.ManifestEntry {
|
||||
dfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, path, iceberg.ParquetFile, map[int]any{}, nil, nil, 1, size)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
return iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfb.Build())
|
||||
}
|
||||
|
||||
entries := []iceberg.ManifestEntry{
|
||||
makeEntry("data/file1.parquet", 1024),
|
||||
makeEntry("data/file2.parquet", 512),
|
||||
}
|
||||
|
||||
// Delete rows 1 (bob) and 3 (dave) from file1
|
||||
posDeletes := map[string][]int64{
|
||||
"data/file1.parquet": {1, 3},
|
||||
}
|
||||
|
||||
merged, count, err := mergeParquetFiles(
|
||||
context.Background(), client, "test-bucket", "ns/tbl",
|
||||
entries, posDeletes, nil, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeParquetFiles: %v", err)
|
||||
}
|
||||
|
||||
// Original: 5 + 2 = 7 rows, deleted 2 = 5 rows
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 rows after position deletes, got %d", count)
|
||||
}
|
||||
|
||||
// Verify merged output is valid Parquet
|
||||
reader := parquet.NewReader(bytes.NewReader(merged))
|
||||
defer reader.Close()
|
||||
var outputRows []dataRow
|
||||
for {
|
||||
var r dataRow
|
||||
err := reader.Read(&r)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read merged row: %v", err)
|
||||
}
|
||||
outputRows = append(outputRows, r)
|
||||
}
|
||||
|
||||
if len(outputRows) != 5 {
|
||||
t.Fatalf("expected 5 output rows, got %d", len(outputRows))
|
||||
}
|
||||
|
||||
// Verify bob (id=2) and dave (id=4) are NOT in output
|
||||
for _, r := range outputRows {
|
||||
if r.ID == 2 || r.ID == 4 {
|
||||
t.Errorf("row with id=%d should have been deleted", r.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeParquetFilesWithEqualityDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
type dataRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := parquet.NewWriter(&buf, parquet.SchemaOf(new(dataRow)))
|
||||
dataRows := []dataRow{
|
||||
{1, "alice"}, {2, "bob"}, {3, "charlie"}, {4, "dave"},
|
||||
}
|
||||
for _, r := range dataRows {
|
||||
if err := w.Write(&r); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, "data1.parquet", &filer_pb.Entry{
|
||||
Name: "data1.parquet",
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
dfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, "data/data1.parquet", iceberg.ParquetFile, map[int]any{}, nil, nil, 4, int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entries := []iceberg.ManifestEntry{
|
||||
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfb.Build()),
|
||||
}
|
||||
|
||||
// Build equality deletes via the collect pipeline so keys match the
|
||||
// encoding used by mergeParquetFiles.
|
||||
schema := newTestSchema() // field 1=id, field 2=name
|
||||
|
||||
type eqDeleteRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
var eqBuf bytes.Buffer
|
||||
eqWriter := parquet.NewWriter(&eqBuf, parquet.SchemaOf(new(eqDeleteRow)))
|
||||
for _, r := range []eqDeleteRow{{0, "bob"}, {0, "dave"}} {
|
||||
if err := eqWriter.Write(&r); err != nil {
|
||||
t.Fatalf("write eq delete: %v", err)
|
||||
}
|
||||
}
|
||||
if err := eqWriter.Close(); err != nil {
|
||||
t.Fatalf("close eq writer: %v", err)
|
||||
}
|
||||
eqDataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(eqDataDir, "eq-del.parquet", &filer_pb.Entry{
|
||||
Name: "eq-del.parquet", Content: eqBuf.Bytes(),
|
||||
})
|
||||
|
||||
eqDfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentEqDeletes,
|
||||
"data/eq-del.parquet", iceberg.ParquetFile, map[int]any{}, nil, nil, 2, int64(eqBuf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("build eq delete: %v", err)
|
||||
}
|
||||
eqDfb.EqualityFieldIDs([]int{2}) // field 2 = name
|
||||
eqEntry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, eqDfb.Build())
|
||||
|
||||
eqGroups, err := collectEqualityDeletes(context.Background(), client, "test-bucket", "ns/tbl",
|
||||
[]iceberg.ManifestEntry{eqEntry}, schema)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEqualityDeletes: %v", err)
|
||||
}
|
||||
|
||||
merged, count, err := mergeParquetFiles(
|
||||
context.Background(), client, "test-bucket", "ns/tbl",
|
||||
entries, nil, eqGroups, schema,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeParquetFiles: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 rows after equality deletes, got %d", count)
|
||||
}
|
||||
|
||||
reader := parquet.NewReader(bytes.NewReader(merged))
|
||||
defer reader.Close()
|
||||
var outputRows []dataRow
|
||||
for {
|
||||
var r dataRow
|
||||
err := reader.Read(&r)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
outputRows = append(outputRows, r)
|
||||
}
|
||||
|
||||
for _, r := range outputRows {
|
||||
if r.Name == "bob" || r.Name == "dave" {
|
||||
t.Errorf("row %q should have been deleted", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectNilRequest(t *testing.T) {
|
||||
handler := NewHandler(nil)
|
||||
err := handler.Detect(nil, nil, nil)
|
||||
|
||||
@@ -38,16 +38,17 @@ func (h *Handler) expireSnapshots(
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
return "no snapshots", nil
|
||||
return "no snapshots", nil, nil
|
||||
}
|
||||
|
||||
// Determine which snapshots to expire
|
||||
@@ -92,7 +93,7 @@ func (h *Handler) expireSnapshots(
|
||||
}
|
||||
|
||||
if len(toExpire) == 0 {
|
||||
return "no snapshots expired", nil
|
||||
return "no snapshots expired", nil, nil
|
||||
}
|
||||
|
||||
// Split snapshots into expired and kept sets
|
||||
@@ -113,11 +114,11 @@ func (h *Handler) expireSnapshots(
|
||||
// This lets us determine which files become unreferenced.
|
||||
expiredFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, expiredSnaps)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect expired snapshot files: %w", err)
|
||||
return "", nil, fmt.Errorf("collect expired snapshot files: %w", err)
|
||||
}
|
||||
keptFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, keptSnaps)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect kept snapshot files: %w", err)
|
||||
return "", nil, fmt.Errorf("collect kept snapshot files: %w", err)
|
||||
}
|
||||
|
||||
// Normalize kept file paths for consistent comparison
|
||||
@@ -136,7 +137,7 @@ func (h *Handler) expireSnapshots(
|
||||
return builder.RemoveSnapshots(toExpire)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit snapshot expiration: %w", err)
|
||||
return "", nil, fmt.Errorf("commit snapshot expiration: %w", err)
|
||||
}
|
||||
|
||||
// Delete files exclusively referenced by expired snapshots (best-effort)
|
||||
@@ -156,7 +157,12 @@ func (h *Handler) expireSnapshots(
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("expired %d snapshot(s), deleted %d unreferenced file(s)", len(toExpire), deletedCount), nil
|
||||
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
|
||||
}
|
||||
|
||||
// collectSnapshotFiles returns all file paths (manifest lists, manifest files,
|
||||
@@ -215,38 +221,62 @@ func (h *Handler) removeOrphans(
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
// Collect all referenced files from all snapshots
|
||||
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, tablePath, meta, 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, tablePath string,
|
||||
meta table.Metadata,
|
||||
metadataFileName string,
|
||||
orphanOlderThanHours int64,
|
||||
) ([]filerFileEntry, error) {
|
||||
referencedFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, meta.Snapshots())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect referenced files: %w", err)
|
||||
return nil, fmt.Errorf("collect referenced files: %w", err)
|
||||
}
|
||||
|
||||
// Reference the active metadata file so it is not treated as orphan
|
||||
referencedFiles[path.Join("metadata", metadataFileName)] = struct{}{}
|
||||
|
||||
// Also reference the current metadata files
|
||||
for mle := range meta.PreviousFiles() {
|
||||
referencedFiles[mle.MetadataFile] = struct{}{}
|
||||
}
|
||||
|
||||
// Precompute a normalized lookup set so orphan checks are O(1) per file.
|
||||
normalizedRefs := make(map[string]struct{}, len(referencedFiles))
|
||||
for ref := range referencedFiles {
|
||||
normalizedRefs[ref] = struct{}{}
|
||||
normalizedRefs[normalizeIcebergPath(ref, bucketName, tablePath)] = struct{}{}
|
||||
}
|
||||
|
||||
// List actual files on filer in metadata/ and data/ directories
|
||||
tableBasePath := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
safetyThreshold := time.Now().Add(-time.Duration(config.OrphanOlderThanHours) * time.Hour)
|
||||
orphanCount := 0
|
||||
safetyThreshold := time.Now().Add(-time.Duration(orphanOlderThanHours) * time.Hour)
|
||||
var candidates []filerFileEntry
|
||||
|
||||
for _, subdir := range []string{"metadata", "data"} {
|
||||
dirPath := path.Join(tableBasePath, subdir)
|
||||
@@ -258,35 +288,22 @@ func (h *Handler) removeOrphans(
|
||||
|
||||
for _, fe := range fileEntries {
|
||||
entry := fe.Entry
|
||||
// Build relative path from the table base (e.g. "data/region=us/file.parquet")
|
||||
fullPath := path.Join(fe.Dir, entry.Name)
|
||||
relPath := strings.TrimPrefix(fullPath, tableBasePath+"/")
|
||||
|
||||
_, isReferenced := normalizedRefs[relPath]
|
||||
|
||||
if isReferenced {
|
||||
if _, isReferenced := normalizedRefs[relPath]; isReferenced {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check safety window — skip entries with unknown age
|
||||
if entry.Attributes == nil {
|
||||
continue
|
||||
}
|
||||
mtime := time.Unix(entry.Attributes.Mtime, 0)
|
||||
if mtime.After(safetyThreshold) {
|
||||
if time.Unix(entry.Attributes.Mtime, 0).After(safetyThreshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete orphan
|
||||
if delErr := deleteFilerFile(ctx, filerClient, fe.Dir, entry.Name); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete orphan %s/%s: %v", fe.Dir, entry.Name, delErr)
|
||||
} else {
|
||||
orphanCount++
|
||||
}
|
||||
candidates = append(candidates, fe)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("removed %d orphan file(s)", orphanCount), nil
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -299,31 +316,41 @@ func (h *Handler) rewriteManifests(
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return "no current snapshot", nil
|
||||
return "no current snapshot", nil, nil
|
||||
}
|
||||
|
||||
// Read manifest list
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(manifestListData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
|
||||
if int64(len(manifests)) < config.MinInputFiles {
|
||||
return fmt.Sprintf("only %d manifests, below threshold of %d", len(manifests), config.MinInputFiles), nil
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if int64(len(dataManifests)) < config.MinManifestsToRewrite {
|
||||
return fmt.Sprintf("only %d data manifests, below threshold of %d", len(dataManifests), config.MinManifestsToRewrite), nil, nil
|
||||
}
|
||||
|
||||
// Collect all entries from data manifests, grouped by partition spec ID
|
||||
@@ -341,17 +368,14 @@ func (h *Handler) rewriteManifests(
|
||||
specByID[ps.ID()] = ps
|
||||
}
|
||||
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
for _, mf := range dataManifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
return "", nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
return "", nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
|
||||
sid := mf.PartitionSpecID()
|
||||
@@ -359,7 +383,7 @@ func (h *Handler) rewriteManifests(
|
||||
if !ok {
|
||||
ps, found := specByID[int(sid)]
|
||||
if !found {
|
||||
return "", fmt.Errorf("partition spec %d not found in table metadata", sid)
|
||||
return "", nil, fmt.Errorf("partition spec %d not found in table metadata", sid)
|
||||
}
|
||||
se = &specEntries{specID: sid, spec: ps}
|
||||
specMap[sid] = se
|
||||
@@ -368,7 +392,7 @@ func (h *Handler) rewriteManifests(
|
||||
}
|
||||
|
||||
if len(specMap) == 0 {
|
||||
return "no data entries to rewrite", nil
|
||||
return "no data entries to rewrite", nil, nil
|
||||
}
|
||||
|
||||
schema := meta.CurrentSchema()
|
||||
@@ -376,6 +400,7 @@ func (h *Handler) rewriteManifests(
|
||||
snapshotID := currentSnap.SnapshotID
|
||||
newSnapshotID := time.Now().UnixMilli()
|
||||
newSeqNum := currentSnap.SequenceNumber + 1
|
||||
artifactSuffix := compactRandomSuffix()
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, tablePath, "metadata")
|
||||
|
||||
// Track written artifacts so we can clean them up if the commit fails.
|
||||
@@ -403,7 +428,7 @@ func (h *Handler) rewriteManifests(
|
||||
totalEntries := 0
|
||||
for _, se := range specMap {
|
||||
totalEntries += len(se.entries)
|
||||
manifestFileName := fmt.Sprintf("merged-%d-spec%d-%d.avro", newSnapshotID, se.specID, time.Now().UnixMilli())
|
||||
manifestFileName := fmt.Sprintf("merged-%d-%s-spec%d.avro", newSnapshotID, artifactSuffix, se.specID)
|
||||
manifestPath := path.Join("metadata", manifestFileName)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
@@ -417,11 +442,11 @@ func (h *Handler) rewriteManifests(
|
||||
se.entries,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write merged manifest for spec %d: %w", se.specID, err)
|
||||
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 "", fmt.Errorf("save merged manifest for spec %d: %w", se.specID, err)
|
||||
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)
|
||||
@@ -437,13 +462,13 @@ func (h *Handler) rewriteManifests(
|
||||
var manifestListBuf bytes.Buffer
|
||||
err = iceberg.WriteManifestList(version, &manifestListBuf, newSnapshotID, &snapshotID, &newSeqNum, 0, newManifests)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("write manifest list: %w", err)
|
||||
}
|
||||
|
||||
// Save new manifest list
|
||||
manifestListFileName := fmt.Sprintf("snap-%d-%d.avro", newSnapshotID, time.Now().UnixMilli())
|
||||
manifestListFileName := fmt.Sprintf("snap-%d-%s.avro", newSnapshotID, artifactSuffix)
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListFileName, manifestListBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("save manifest list: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListFileName})
|
||||
|
||||
@@ -484,11 +509,16 @@ func (h *Handler) rewriteManifests(
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit manifest rewrite: %w", err)
|
||||
return "", nil, fmt.Errorf("commit manifest rewrite: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", len(manifests), len(specMap), totalEntries), nil
|
||||
metrics := map[string]int64{
|
||||
MetricManifestsRewritten: int64(len(dataManifests)),
|
||||
MetricEntriesTotal: int64(totalEntries),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", len(dataManifests), len(specMap), totalEntries), metrics, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -87,8 +87,44 @@ func (h *VacuumHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only scan this collection when set.",
|
||||
Placeholder: "all collections",
|
||||
Description: "Filter collections for vacuum detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a regex pattern to match specific collections.",
|
||||
Placeholder: "ALL_COLLECTIONS",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "volume_state",
|
||||
Label: "Volume State Filter",
|
||||
Description: "Filter volumes by state: ALL (default), ACTIVE (writable volumes below size limit), or FULL (read-only volumes above size limit).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
|
||||
Options: []*plugin_pb.ConfigOption{
|
||||
{Value: string(volumeStateAll), Label: "All Volumes"},
|
||||
{Value: string(volumeStateActive), Label: "Active (writable)"},
|
||||
{Value: string(volumeStateFull), Label: "Full (read-only)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only vacuum volumes in matching data centers (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "rack_filter",
|
||||
Label: "Rack Filter",
|
||||
Description: "Only vacuum volumes on matching racks (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all racks",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "node_filter",
|
||||
Label: "Node Filter",
|
||||
Description: "Only vacuum volumes on matching nodes (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all nodes",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
@@ -99,6 +135,18 @@ func (h *VacuumHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
"collection_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"volume_state": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: string(volumeStateAll)},
|
||||
},
|
||||
"data_center_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"rack_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"node_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
@@ -226,6 +274,17 @@ func (h *VacuumHandler) Detect(ctx context.Context, request *plugin_pb.RunDetect
|
||||
return err
|
||||
}
|
||||
|
||||
volState := volumeState(strings.ToUpper(strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "volume_state", string(volumeStateAll)))))
|
||||
metrics = filterMetricsByVolumeState(metrics, volState)
|
||||
|
||||
dataCenterFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
rackFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "rack_filter", ""))
|
||||
nodeFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "node_filter", ""))
|
||||
|
||||
if dataCenterFilter != "" || rackFilter != "" || nodeFilter != "" {
|
||||
metrics = filterMetricsByLocation(metrics, dataCenterFilter, rackFilter, nodeFilter)
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
results, err := vacuumtask.Detection(metrics, clusterInfo, workerConfig)
|
||||
if err != nil {
|
||||
@@ -505,7 +564,8 @@ func (h *VacuumHandler) collectVolumeMetrics(
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
return collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
metrics, activeTopology, _, err := collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveVacuumConfig(values map[string]*plugin_pb.ConfigValue) *vacuumtask.Config {
|
||||
@@ -544,6 +604,10 @@ func buildVacuumProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.Jo
|
||||
summary = summary + " on " + result.Server
|
||||
}
|
||||
|
||||
// Estimate runtime: 5 min/GB (compact + commit + overhead)
|
||||
volumeSizeGB := int64(result.TypedParams.VolumeSize/1024/1024/1024) + 1
|
||||
estimatedRuntimeSeconds := volumeSizeGB * 5 * 60
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: proposalID,
|
||||
DedupeKey: dedupeKey,
|
||||
@@ -564,6 +628,9 @@ func buildVacuumProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.Jo
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
|
||||
},
|
||||
"estimated_runtime_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: estimatedRuntimeSeconds},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "vacuum",
|
||||
|
||||
@@ -242,6 +242,138 @@ func TestEmitVacuumDetectionDecisionTraceNoTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumDescriptorHasNewFields(t *testing.T) {
|
||||
handler := NewVacuumHandler(nil, 2)
|
||||
desc := handler.Descriptor()
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
adminForm := desc.AdminConfigForm
|
||||
if adminForm == nil {
|
||||
t.Fatal("admin config form is nil")
|
||||
}
|
||||
if len(adminForm.Sections) == 0 {
|
||||
t.Fatal("admin config form has no sections")
|
||||
}
|
||||
|
||||
scopeSection := adminForm.Sections[0]
|
||||
fieldNames := make(map[string]*plugin_pb.ConfigField)
|
||||
for _, f := range scopeSection.Fields {
|
||||
fieldNames[f.Name] = f
|
||||
}
|
||||
|
||||
requiredFields := []string{
|
||||
"collection_filter",
|
||||
"volume_state",
|
||||
"data_center_filter",
|
||||
"rack_filter",
|
||||
"node_filter",
|
||||
}
|
||||
for _, name := range requiredFields {
|
||||
if _, ok := fieldNames[name]; !ok {
|
||||
t.Errorf("missing field %q in admin config scope section", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify volume_state is an enum with correct options.
|
||||
vsField := fieldNames["volume_state"]
|
||||
if vsField.FieldType != plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM {
|
||||
t.Errorf("volume_state field type = %v, want ENUM", vsField.FieldType)
|
||||
}
|
||||
if len(vsField.Options) != 3 {
|
||||
t.Errorf("volume_state options count = %d, want 3", len(vsField.Options))
|
||||
}
|
||||
|
||||
// Verify default values exist for new fields.
|
||||
defaultKeys := []string{"volume_state", "data_center_filter", "rack_filter", "node_filter"}
|
||||
for _, key := range defaultKeys {
|
||||
if _, ok := adminForm.DefaultValues[key]; !ok {
|
||||
t.Errorf("missing default value for %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify volume_state default is ALL.
|
||||
vsDefault := adminForm.DefaultValues["volume_state"]
|
||||
if sv, ok := vsDefault.Kind.(*plugin_pb.ConfigValue_StringValue); !ok || sv.StringValue != "ALL" {
|
||||
t.Errorf("volume_state default = %v, want ALL", vsDefault)
|
||||
}
|
||||
|
||||
// Verify collection_filter description mentions ALL_COLLECTIONS.
|
||||
cfField := fieldNames["collection_filter"]
|
||||
if cfField.Placeholder != "ALL_COLLECTIONS" {
|
||||
t.Errorf("collection_filter placeholder = %q, want ALL_COLLECTIONS", cfField.Placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumFiltersVolumeState(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||
{VolumeID: 2, FullnessRatio: 1.5}, // full
|
||||
{VolumeID: 3, FullnessRatio: 0.9}, // active
|
||||
{VolumeID: 4, FullnessRatio: 1.01}, // full (at threshold)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state volumeState
|
||||
wantIDs []uint32
|
||||
}{
|
||||
{"ALL returns all", volumeStateAll, []uint32{1, 2, 3, 4}},
|
||||
{"ACTIVE returns writable", volumeStateActive, []uint32{1, 3}},
|
||||
{"FULL returns read-only", volumeStateFull, []uint32{2, 4}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := filterMetricsByVolumeState(metrics, tt.state)
|
||||
if len(filtered) != len(tt.wantIDs) {
|
||||
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
|
||||
}
|
||||
for i, m := range filtered {
|
||||
if m.VolumeID != tt.wantIDs[i] {
|
||||
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumFiltersLocation(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, DataCenter: "dc1", Rack: "r1", Server: "s1"},
|
||||
{VolumeID: 2, DataCenter: "dc1", Rack: "r2", Server: "s2"},
|
||||
{VolumeID: 3, DataCenter: "dc2", Rack: "r1", Server: "s3"},
|
||||
{VolumeID: 4, DataCenter: "dc2", Rack: "r3", Server: "s4"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dc string
|
||||
rack string
|
||||
node string
|
||||
wantIDs []uint32
|
||||
}{
|
||||
{"dc filter", "dc1", "", "", []uint32{1, 2}},
|
||||
{"rack filter", "", "r1", "", []uint32{1, 3}},
|
||||
{"node filter", "", "", "s2,s4", []uint32{2, 4}},
|
||||
{"dc + rack", "dc1", "r1", "", []uint32{1}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := filterMetricsByLocation(metrics, tt.dc, tt.rack, tt.node)
|
||||
if len(filtered) != len(tt.wantIDs) {
|
||||
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
|
||||
}
|
||||
for i, m := range filtered {
|
||||
if m.VolumeID != tt.wantIDs[i] {
|
||||
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type noopDetectionSender struct{}
|
||||
|
||||
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
balancetask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
@@ -25,6 +26,23 @@ const (
|
||||
maxProposalStringLength = 200
|
||||
)
|
||||
|
||||
// collectionFilterMode controls how collections are handled during balance detection.
|
||||
type collectionFilterMode string
|
||||
|
||||
const (
|
||||
collectionFilterAll collectionFilterMode = "ALL_COLLECTIONS"
|
||||
collectionFilterEach collectionFilterMode = "EACH_COLLECTION"
|
||||
)
|
||||
|
||||
// volumeState controls which volumes participate in balance detection.
|
||||
type volumeState string
|
||||
|
||||
const (
|
||||
volumeStateAll volumeState = "ALL"
|
||||
volumeStateActive volumeState = "ACTIVE"
|
||||
volumeStateFull volumeState = "FULL"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "volume_balance",
|
||||
@@ -85,8 +103,44 @@ func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only detect balance opportunities in this collection when set.",
|
||||
Placeholder: "all collections",
|
||||
Description: "Filter collections for balance detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a regex pattern to match specific collections.",
|
||||
Placeholder: "ALL_COLLECTIONS",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "volume_state",
|
||||
Label: "Volume State Filter",
|
||||
Description: "Filter volumes by state: ALL (default), ACTIVE (writable volumes below size limit), or FULL (read-only volumes above size limit).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
|
||||
Options: []*plugin_pb.ConfigOption{
|
||||
{Value: string(volumeStateAll), Label: "All Volumes"},
|
||||
{Value: string(volumeStateActive), Label: "Active (writable)"},
|
||||
{Value: string(volumeStateFull), Label: "Full (read-only)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only balance volumes in matching data centers (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "rack_filter",
|
||||
Label: "Rack Filter",
|
||||
Description: "Only balance volumes on matching racks (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all racks",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "node_filter",
|
||||
Label: "Node Filter",
|
||||
Description: "Only balance volumes on matching nodes (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all nodes",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
@@ -97,6 +151,18 @@ func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
"collection_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"volume_state": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: string(volumeStateAll)},
|
||||
},
|
||||
"data_center_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"rack_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"node_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
@@ -261,16 +327,79 @@ func (h *VolumeBalanceHandler) Detect(
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
metrics, activeTopology, replicaMap, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
volState := volumeState(strings.ToUpper(strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "volume_state", string(volumeStateAll)))))
|
||||
metrics = filterMetricsByVolumeState(metrics, volState)
|
||||
|
||||
dataCenterFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
rackFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "rack_filter", ""))
|
||||
nodeFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "node_filter", ""))
|
||||
|
||||
if dataCenterFilter != "" || rackFilter != "" || nodeFilter != "" {
|
||||
metrics = filterMetricsByLocation(metrics, dataCenterFilter, rackFilter, nodeFilter)
|
||||
}
|
||||
|
||||
workerConfig.TaskConfig.DataCenterFilter = dataCenterFilter
|
||||
workerConfig.TaskConfig.RackFilter = rackFilter
|
||||
workerConfig.TaskConfig.NodeFilter = nodeFilter
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{
|
||||
ActiveTopology: activeTopology,
|
||||
VolumeReplicaMap: replicaMap,
|
||||
}
|
||||
maxResults := int(request.MaxResults)
|
||||
results, hasMore, err := balancetask.Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
var results []*workertypes.TaskDetectionResult
|
||||
var hasMore bool
|
||||
|
||||
if collectionFilterMode(collectionFilter) == collectionFilterEach {
|
||||
// Group metrics by collection in a single pass (O(N) instead of O(C*N))
|
||||
metricsByCollection := make(map[string][]*workertypes.VolumeHealthMetrics)
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
metricsByCollection[m.Collection] = append(metricsByCollection[m.Collection], m)
|
||||
}
|
||||
collections := make([]string, 0, len(metricsByCollection))
|
||||
for c := range metricsByCollection {
|
||||
collections = append(collections, c)
|
||||
}
|
||||
sort.Strings(collections)
|
||||
|
||||
budget := maxResults
|
||||
unlimitedBudget := budget <= 0
|
||||
for _, collection := range collections {
|
||||
if !unlimitedBudget && budget <= 0 {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
perCollectionLimit := budget
|
||||
if unlimitedBudget {
|
||||
perCollectionLimit = 0 // Detection treats <= 0 as unbounded
|
||||
}
|
||||
perResults, perHasMore, perErr := balancetask.Detection(metricsByCollection[collection], clusterInfo, workerConfig.TaskConfig, perCollectionLimit)
|
||||
if perErr != nil {
|
||||
return perErr
|
||||
}
|
||||
results = append(results, perResults...)
|
||||
if !unlimitedBudget {
|
||||
budget -= len(perResults)
|
||||
}
|
||||
if perHasMore {
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
results, hasMore, err = balancetask.Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if traceErr := emitVolumeBalanceDetectionDecisionTrace(sender, metrics, activeTopology, workerConfig.TaskConfig, results); traceErr != nil {
|
||||
@@ -544,6 +673,39 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterMetricsByVolumeState filters volume metrics by state.
|
||||
// "ACTIVE" keeps volumes with FullnessRatio < 1.01 (writable, below size limit).
|
||||
// "FULL" keeps volumes with FullnessRatio >= 1.01 (read-only, above size limit).
|
||||
// "ALL" or any other value returns all metrics unfiltered.
|
||||
func filterMetricsByVolumeState(metrics []*workertypes.VolumeHealthMetrics, state volumeState) []*workertypes.VolumeHealthMetrics {
|
||||
const fullnessThreshold = 1.01
|
||||
|
||||
var predicate func(m *workertypes.VolumeHealthMetrics) bool
|
||||
switch state {
|
||||
case volumeStateActive:
|
||||
predicate = func(m *workertypes.VolumeHealthMetrics) bool {
|
||||
return m.FullnessRatio < fullnessThreshold
|
||||
}
|
||||
case volumeStateFull:
|
||||
predicate = func(m *workertypes.VolumeHealthMetrics) bool {
|
||||
return m.FullnessRatio >= fullnessThreshold
|
||||
}
|
||||
default:
|
||||
return metrics
|
||||
}
|
||||
|
||||
filtered := make([]*workertypes.VolumeHealthMetrics, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if predicate(m) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func countBalanceDiskTypes(metrics []*workertypes.VolumeHealthMetrics) int {
|
||||
diskTypes := make(map[string]struct{})
|
||||
for _, metric := range metrics {
|
||||
@@ -926,10 +1088,8 @@ func (h *VolumeBalanceHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
// Reuse the same master topology fetch/build flow used by the vacuum handler.
|
||||
helper := &VacuumHandler{grpcDialOption: h.grpcDialOption}
|
||||
return helper.collectVolumeMetrics(ctx, masterAddresses, collectionFilter)
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
|
||||
return collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
}
|
||||
|
||||
func deriveBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *volumeBalanceWorkerConfig {
|
||||
@@ -981,6 +1141,30 @@ func deriveBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *volume
|
||||
}
|
||||
}
|
||||
|
||||
func filterMetricsByLocation(metrics []*workertypes.VolumeHealthMetrics, dcFilter, rackFilter, nodeFilter string) []*workertypes.VolumeHealthMetrics {
|
||||
dcMatchers := wildcard.CompileWildcardMatchers(dcFilter)
|
||||
rackMatchers := wildcard.CompileWildcardMatchers(rackFilter)
|
||||
nodeMatchers := wildcard.CompileWildcardMatchers(nodeFilter)
|
||||
|
||||
filtered := make([]*workertypes.VolumeHealthMetrics, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if !wildcard.MatchesAnyWildcard(dcMatchers, m.DataCenter) {
|
||||
continue
|
||||
}
|
||||
if !wildcard.MatchesAnyWildcard(rackMatchers, m.Rack) {
|
||||
continue
|
||||
}
|
||||
if !wildcard.MatchesAnyWildcard(nodeMatchers, m.Server) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func buildVolumeBalanceProposal(
|
||||
result *workertypes.TaskDetectionResult,
|
||||
) (*plugin_pb.JobProposal, error) {
|
||||
@@ -1207,7 +1391,7 @@ func buildBatchVolumeBalanceProposals(
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "balance",
|
||||
"batch": "true",
|
||||
"batch": "true",
|
||||
"batch_size": fmt.Sprintf("%d", len(moves)),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -629,6 +629,157 @@ func TestExecuteSingleMovePathUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByLocation(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, Server: "node-a", DataCenter: "dc1", Rack: "rack1"},
|
||||
{VolumeID: 2, Server: "node-b", DataCenter: "dc1", Rack: "rack2"},
|
||||
{VolumeID: 3, Server: "node-c", DataCenter: "dc2", Rack: "rack1"},
|
||||
{VolumeID: 4, Server: "node-d", DataCenter: "dc2", Rack: "rack3"},
|
||||
}
|
||||
|
||||
// Filter by DC
|
||||
filtered := filterMetricsByLocation(metrics, "dc1", "", "")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("DC filter: expected 2, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Filter by rack
|
||||
filtered = filterMetricsByLocation(metrics, "", "rack1,rack2", "")
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("rack filter: expected 3, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Filter by node
|
||||
filtered = filterMetricsByLocation(metrics, "", "", "node-a,node-c")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("node filter: expected 2, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Combined DC + rack
|
||||
filtered = filterMetricsByLocation(metrics, "dc2", "rack3", "")
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("DC+rack filter: expected 1, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Empty filters pass all
|
||||
filtered = filterMetricsByLocation(metrics, "", "", "")
|
||||
if len(filtered) != 4 {
|
||||
t.Fatalf("no filter: expected 4, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||
{VolumeID: 2, FullnessRatio: 1.0}, // active (below 1.01)
|
||||
{VolumeID: 3, FullnessRatio: 1.009}, // active (below 1.01)
|
||||
{VolumeID: 4, FullnessRatio: 1.01}, // full (exactly at threshold)
|
||||
{VolumeID: 5, FullnessRatio: 1.5}, // full
|
||||
{VolumeID: 6, FullnessRatio: 2.0}, // full
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
expectedIDs []uint32
|
||||
}{
|
||||
{
|
||||
name: "ALL returns everything",
|
||||
state: "ALL",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "empty string returns everything",
|
||||
state: "",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "ACTIVE keeps FullnessRatio below 1.01",
|
||||
state: "ACTIVE",
|
||||
expectedIDs: []uint32{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "FULL keeps FullnessRatio at or above 1.01",
|
||||
state: "FULL",
|
||||
expectedIDs: []uint32{4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "unknown value returns everything",
|
||||
state: "INVALID",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := filterMetricsByVolumeState(metrics, volumeState(tt.state))
|
||||
if len(result) != len(tt.expectedIDs) {
|
||||
t.Fatalf("expected %d metrics, got %d", len(tt.expectedIDs), len(result))
|
||||
}
|
||||
for i, m := range result {
|
||||
if m.VolumeID != tt.expectedIDs[i] {
|
||||
t.Errorf("result[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.expectedIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState_NilElement(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
nil,
|
||||
{VolumeID: 1, FullnessRatio: 0.5},
|
||||
nil,
|
||||
{VolumeID: 2, FullnessRatio: 1.5},
|
||||
}
|
||||
result := filterMetricsByVolumeState(metrics, volumeStateActive)
|
||||
if len(result) != 1 || result[0].VolumeID != 1 {
|
||||
t.Fatalf("expected [vol 1] for ACTIVE with nil elements, got %d results", len(result))
|
||||
}
|
||||
result = filterMetricsByVolumeState(metrics, volumeStateFull)
|
||||
if len(result) != 1 || result[0].VolumeID != 2 {
|
||||
t.Fatalf("expected [vol 2] for FULL with nil elements, got %d results", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState_EmptyInput(t *testing.T) {
|
||||
result := filterMetricsByVolumeState(nil, volumeStateActive)
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 metrics for nil input, got %d", len(result))
|
||||
}
|
||||
|
||||
result = filterMetricsByVolumeState([]*workertypes.VolumeHealthMetrics{}, volumeStateFull)
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 metrics for empty input, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeBalanceDescriptorHasVolumeStateField(t *testing.T) {
|
||||
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
|
||||
if descriptor == nil || descriptor.AdminConfigForm == nil {
|
||||
t.Fatalf("expected admin config form in descriptor")
|
||||
}
|
||||
found := false
|
||||
for _, section := range descriptor.AdminConfigForm.Sections {
|
||||
for _, field := range section.Fields {
|
||||
if field.Name == "volume_state" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected volume_state field in admin config form")
|
||||
}
|
||||
defaultVal, ok := descriptor.AdminConfigForm.DefaultValues["volume_state"]
|
||||
if !ok {
|
||||
t.Fatalf("expected volume_state default value")
|
||||
}
|
||||
if defaultVal.GetStringValue() != "ALL" {
|
||||
t.Fatalf("expected volume_state default 'ALL', got %q", defaultVal.GetStringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
|
||||
if form == nil {
|
||||
return false
|
||||
|
||||
@@ -2,7 +2,9 @@ package pluginworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,7 +13,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
@@ -21,12 +22,12 @@ func collectVolumeMetricsFromMasters(
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
grpcDialOption grpc.DialOption,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
|
||||
if grpcDialOption == nil {
|
||||
return nil, nil, fmt.Errorf("grpc dial option is not configured")
|
||||
return nil, nil, nil, fmt.Errorf("grpc dial option is not configured")
|
||||
}
|
||||
if len(masterAddresses) == 0 {
|
||||
return nil, nil, fmt.Errorf("no master addresses provided in cluster context")
|
||||
return nil, nil, nil, fmt.Errorf("no master addresses provided in cluster context")
|
||||
}
|
||||
|
||||
for _, masterAddress := range masterAddresses {
|
||||
@@ -36,15 +37,20 @@ func collectVolumeMetricsFromMasters(
|
||||
continue
|
||||
}
|
||||
|
||||
metrics, activeTopology, buildErr := buildVolumeMetrics(response, collectionFilter)
|
||||
metrics, activeTopology, replicaMap, buildErr := buildVolumeMetrics(response, collectionFilter)
|
||||
if buildErr != nil {
|
||||
// Configuration errors (e.g. invalid regex) will fail on every master,
|
||||
// so return immediately instead of masking them with retries.
|
||||
if isConfigError(buildErr) {
|
||||
return nil, nil, nil, buildErr
|
||||
}
|
||||
glog.Warningf("Plugin worker failed to build metrics from master %s: %v", masterAddress, buildErr)
|
||||
continue
|
||||
}
|
||||
return metrics, activeTopology, nil
|
||||
return metrics, activeTopology, replicaMap, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to load topology from all provided masters")
|
||||
return nil, nil, nil, fmt.Errorf("failed to load topology from all provided masters")
|
||||
}
|
||||
|
||||
func fetchVolumeList(ctx context.Context, address string, grpcDialOption grpc.DialOption) (*master_pb.VolumeListResponse, error) {
|
||||
@@ -83,27 +89,46 @@ func fetchVolumeList(ctx context.Context, address string, grpcDialOption grpc.Di
|
||||
func buildVolumeMetrics(
|
||||
response *master_pb.VolumeListResponse,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
|
||||
if response == nil || response.TopologyInfo == nil {
|
||||
return nil, nil, fmt.Errorf("volume list response has no topology info")
|
||||
return nil, nil, nil, fmt.Errorf("volume list response has no topology info")
|
||||
}
|
||||
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
if err := activeTopology.UpdateTopology(response.TopologyInfo); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var collectionRegex *regexp.Regexp
|
||||
trimmedFilter := strings.TrimSpace(collectionFilter)
|
||||
filterMode := collectionFilterMode(trimmedFilter)
|
||||
if trimmedFilter != "" && filterMode != collectionFilterAll && filterMode != collectionFilterEach && trimmedFilter != "*" {
|
||||
var err error
|
||||
collectionRegex, err = regexp.Compile(trimmedFilter)
|
||||
if err != nil {
|
||||
return nil, nil, nil, &configError{err: fmt.Errorf("invalid collection_filter regex %q: %w", trimmedFilter, err)}
|
||||
}
|
||||
}
|
||||
|
||||
patterns := wildcard.CompileWildcardMatchers(collectionFilter)
|
||||
volumeSizeLimitBytes := uint64(response.VolumeSizeLimitMb) * 1024 * 1024
|
||||
now := time.Now()
|
||||
metrics := make([]*workertypes.VolumeHealthMetrics, 0, 256)
|
||||
replicaMap := make(map[uint32][]workertypes.ReplicaLocation)
|
||||
|
||||
for _, dc := range response.TopologyInfo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
for _, node := range rack.DataNodeInfos {
|
||||
for diskType, diskInfo := range node.DiskInfos {
|
||||
for _, volume := range diskInfo.VolumeInfos {
|
||||
if !wildcard.MatchesAnyWildcard(patterns, volume.Collection) {
|
||||
// Build replica map from ALL volumes BEFORE collection filtering,
|
||||
// since replicas may span filtered/unfiltered nodes.
|
||||
replicaMap[volume.Id] = append(replicaMap[volume.Id], workertypes.ReplicaLocation{
|
||||
DataCenter: dc.Id,
|
||||
Rack: rack.Id,
|
||||
NodeID: node.Id,
|
||||
})
|
||||
|
||||
if collectionRegex != nil && !collectionRegex.MatchString(volume.Collection) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -145,7 +170,20 @@ func buildVolumeMetrics(
|
||||
metric.ReplicaCount = replicaCounts[metric.VolumeID]
|
||||
}
|
||||
|
||||
return metrics, activeTopology, nil
|
||||
return metrics, activeTopology, replicaMap, nil
|
||||
}
|
||||
|
||||
// configError wraps configuration errors that should not be retried across masters.
|
||||
type configError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *configError) Error() string { return e.err.Error() }
|
||||
func (e *configError) Unwrap() error { return e.err }
|
||||
|
||||
func isConfigError(err error) bool {
|
||||
var ce *configError
|
||||
return errors.As(err, &ce)
|
||||
}
|
||||
|
||||
func masterAddressCandidates(address string) []string {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
func makeTestVolumeListResponse(volumes ...*master_pb.VolumeInformationMessage) *master_pb.VolumeListResponse {
|
||||
return &master_pb.VolumeListResponse{
|
||||
VolumeSizeLimitMb: 30000,
|
||||
TopologyInfo: &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "server1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
VolumeInfos: volumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsEmptyFilter(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
)
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected 2 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsAllCollections(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
)
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, string(collectionFilterAll))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected 2 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsEachCollection(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
)
|
||||
// EACH_COLLECTION passes all volumes through; filtering happens in the handler
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, string(collectionFilterEach))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected 2 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsRegexFilter(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
&master_pb.VolumeInformationMessage{Id: 3, Collection: "photos-backup", Size: 300},
|
||||
)
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, "^photos$")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
if metrics[0].Collection != "photos" {
|
||||
t.Fatalf("expected collection 'photos', got %q", metrics[0].Collection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsInvalidRegex(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
)
|
||||
_, _, _, err := buildVolumeMetrics(resp, "[invalid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid regex")
|
||||
}
|
||||
if !isConfigError(err) {
|
||||
t.Fatalf("expected config error for invalid regex, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,68 @@ type azureRemoteStorageClient struct {
|
||||
|
||||
var _ = remote_storage.RemoteStorageClient(&azureRemoteStorageClient{})
|
||||
|
||||
func (az *azureRemoteStorageClient) ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) (err error) {
|
||||
pathKey := loc.Path[1:]
|
||||
if pathKey != "" && !strings.HasSuffix(pathKey, "/") {
|
||||
pathKey += "/"
|
||||
}
|
||||
|
||||
containerClient := az.client.ServiceClient().NewContainerClient(loc.Bucket)
|
||||
pager := containerClient.NewListBlobsHierarchyPager("/", &container.ListBlobsHierarchyOptions{
|
||||
Prefix: &pathKey,
|
||||
})
|
||||
|
||||
for pager.More() {
|
||||
resp, pageErr := pager.NextPage(ctx)
|
||||
if pageErr != nil {
|
||||
return fmt.Errorf("azure list directory %s%s: %w", loc.Bucket, loc.Path, pageErr)
|
||||
}
|
||||
|
||||
for _, prefix := range resp.Segment.BlobPrefixes {
|
||||
if prefix.Name == nil {
|
||||
continue
|
||||
}
|
||||
dirKey := "/" + strings.TrimSuffix(*prefix.Name, "/")
|
||||
dir, name := util.FullPath(dirKey).DirAndName()
|
||||
if err = visitFn(dir, name, true, nil); err != nil {
|
||||
return fmt.Errorf("azure processing directory prefix %s: %w", *prefix.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, blobItem := range resp.Segment.BlobItems {
|
||||
if blobItem.Name == nil {
|
||||
continue
|
||||
}
|
||||
key := "/" + *blobItem.Name
|
||||
if strings.HasSuffix(key, "/") {
|
||||
continue // skip directory markers
|
||||
}
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
|
||||
remoteEntry := &filer_pb.RemoteEntry{
|
||||
StorageName: az.conf.Name,
|
||||
}
|
||||
if blobItem.Properties != nil {
|
||||
if blobItem.Properties.LastModified != nil {
|
||||
remoteEntry.RemoteMtime = blobItem.Properties.LastModified.Unix()
|
||||
}
|
||||
if blobItem.Properties.ContentLength != nil {
|
||||
remoteEntry.RemoteSize = *blobItem.Properties.ContentLength
|
||||
}
|
||||
if blobItem.Properties.ETag != nil {
|
||||
remoteEntry.RemoteETag = string(*blobItem.Properties.ETag)
|
||||
}
|
||||
}
|
||||
|
||||
if err = visitFn(dir, name, false, remoteEntry); err != nil {
|
||||
return fmt.Errorf("azure processing blob %s: %w", *blobItem.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (az *azureRemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
key := loc.Path[1:]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), DefaultAzureOpTimeout)
|
||||
|
||||
@@ -131,6 +131,52 @@ func (gcs *gcsRemoteStorageClient) Traverse(loc *remote_pb.RemoteStorageLocation
|
||||
|
||||
const defaultGCSOpTimeout = 30 * time.Second
|
||||
|
||||
func (gcs *gcsRemoteStorageClient) ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) (err error) {
|
||||
pathKey := loc.Path[1:]
|
||||
if pathKey != "" && !strings.HasSuffix(pathKey, "/") {
|
||||
pathKey += "/"
|
||||
}
|
||||
|
||||
objectIterator := gcs.client.Bucket(loc.Bucket).Objects(ctx, &storage.Query{
|
||||
Delimiter: "/",
|
||||
Prefix: pathKey,
|
||||
Versions: false,
|
||||
})
|
||||
|
||||
for {
|
||||
objectAttr, iterErr := objectIterator.Next()
|
||||
if iterErr != nil {
|
||||
if iterErr == iterator.Done {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("list directory %s%s: %w", loc.Bucket, loc.Path, iterErr)
|
||||
}
|
||||
|
||||
if objectAttr.Prefix != "" {
|
||||
// Common prefix → subdirectory
|
||||
dirKey := "/" + strings.TrimSuffix(objectAttr.Prefix, "/")
|
||||
dir, name := util.FullPath(dirKey).DirAndName()
|
||||
if err = visitFn(dir, name, true, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
key := "/" + objectAttr.Name
|
||||
if strings.HasSuffix(key, "/") {
|
||||
continue // skip directory markers
|
||||
}
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
if err = visitFn(dir, name, false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: objectAttr.Updated.Unix(),
|
||||
RemoteSize: objectAttr.Size,
|
||||
RemoteETag: objectAttr.Etag,
|
||||
StorageName: gcs.conf.Name,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gcs *gcsRemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
key := loc.Path[1:]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultGCSOpTimeout)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package remote_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -75,6 +76,7 @@ var ErrRemoteObjectNotFound = errors.New("remote object not found")
|
||||
|
||||
type RemoteStorageClient interface {
|
||||
Traverse(loc *remote_pb.RemoteStorageLocation, visitFn VisitFunc) error
|
||||
ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn VisitFunc) error
|
||||
StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error)
|
||||
ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (data []byte, err error)
|
||||
WriteDirectory(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry) (err error)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
@@ -98,12 +100,19 @@ func (s *s3RemoteStorageClient) Traverse(remote *remote_pb.RemoteStorageLocation
|
||||
key := *content.Key
|
||||
key = "/" + key
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
if err := visitFn(dir, name, false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: (*content.LastModified).Unix(),
|
||||
RemoteSize: *content.Size,
|
||||
RemoteETag: *content.ETag,
|
||||
remoteEntry := &filer_pb.RemoteEntry{
|
||||
StorageName: s.conf.Name,
|
||||
}); err != nil {
|
||||
}
|
||||
if content.LastModified != nil {
|
||||
remoteEntry.RemoteMtime = content.LastModified.Unix()
|
||||
}
|
||||
if content.Size != nil {
|
||||
remoteEntry.RemoteSize = *content.Size
|
||||
}
|
||||
if content.ETag != nil {
|
||||
remoteEntry.RemoteETag = *content.ETag
|
||||
}
|
||||
if err := visitFn(dir, name, false, remoteEntry); err != nil {
|
||||
localErr = err
|
||||
return false
|
||||
}
|
||||
@@ -122,6 +131,65 @@ func (s *s3RemoteStorageClient) Traverse(remote *remote_pb.RemoteStorageLocation
|
||||
return
|
||||
}
|
||||
|
||||
func (s *s3RemoteStorageClient) ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
pathKey := loc.Path[1:]
|
||||
if pathKey != "" && !strings.HasSuffix(pathKey, "/") {
|
||||
pathKey += "/"
|
||||
}
|
||||
|
||||
listInput := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Prefix: aws.String(pathKey),
|
||||
Delimiter: aws.String("/"),
|
||||
}
|
||||
|
||||
var localErr error
|
||||
listErr := s.conn.ListObjectsV2PagesWithContext(ctx, listInput, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
|
||||
for _, prefix := range page.CommonPrefixes {
|
||||
if prefix.Prefix == nil {
|
||||
continue
|
||||
}
|
||||
dirKey := "/" + strings.TrimSuffix(*prefix.Prefix, "/")
|
||||
dir, name := util.FullPath(dirKey).DirAndName()
|
||||
if err := visitFn(dir, name, true, nil); err != nil {
|
||||
localErr = err
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, content := range page.Contents {
|
||||
key := "/" + *content.Key
|
||||
if strings.HasSuffix(key, "/") {
|
||||
continue // skip directory markers
|
||||
}
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
remoteEntry := &filer_pb.RemoteEntry{
|
||||
StorageName: s.conf.Name,
|
||||
}
|
||||
if content.LastModified != nil {
|
||||
remoteEntry.RemoteMtime = content.LastModified.Unix()
|
||||
}
|
||||
if content.Size != nil {
|
||||
remoteEntry.RemoteSize = *content.Size
|
||||
}
|
||||
if content.ETag != nil {
|
||||
remoteEntry.RemoteETag = *content.ETag
|
||||
}
|
||||
if err := visitFn(dir, name, false, remoteEntry); err != nil {
|
||||
localErr = err
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if listErr != nil {
|
||||
return fmt.Errorf("list directory %v: %w", loc, listErr)
|
||||
}
|
||||
if localErr != nil {
|
||||
return fmt.Errorf("process directory %v: %w", loc, localErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3RemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
resp, err := s.conn.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
@@ -207,13 +275,16 @@ func (s *s3RemoteStorageClient) WriteFile(loc *remote_pb.RemoteStorageLocation,
|
||||
}
|
||||
|
||||
// Upload the file to S3.
|
||||
_, err = uploader.Upload(&s3manager.UploadInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Key: aws.String(loc.Path[1:]),
|
||||
Body: reader,
|
||||
Tagging: awsTags,
|
||||
StorageClass: aws.String(s.conf.S3StorageClass),
|
||||
})
|
||||
uploadInput := &s3manager.UploadInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Key: aws.String(loc.Path[1:]),
|
||||
Body: reader,
|
||||
Tagging: awsTags,
|
||||
}
|
||||
if s.conf.S3StorageClass != "" {
|
||||
uploadInput.StorageClass = aws.String(s.conf.S3StorageClass)
|
||||
}
|
||||
_, err = uploader.Upload(uploadInput)
|
||||
|
||||
//in case it fails to upload
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -241,17 +242,44 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
|
||||
}
|
||||
|
||||
eofBackoff := time.Duration(0)
|
||||
var partialData []byte
|
||||
var savedFilename string
|
||||
var savedHeader http.Header
|
||||
var savedSourceUrl string
|
||||
retryName := fmt.Sprintf("replicate chunk %s", sourceChunk.GetFileIdString())
|
||||
err = util.RetryUntil(retryName, func() error {
|
||||
filename, header, resp, readErr := fs.filerSource.ReadPart(sourceChunk.GetFileIdString())
|
||||
filename, header, resp, readErr := fs.filerSource.ReadPart(sourceChunk.GetFileIdString(), int64(len(partialData)))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read part %s: %w", sourceChunk.GetFileIdString(), readErr)
|
||||
}
|
||||
defer util_http.CloseResponse(resp)
|
||||
|
||||
sourceUrl := ""
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
sourceUrl = resp.Request.URL.String()
|
||||
// Save metadata from first successful response
|
||||
if len(partialData) == 0 {
|
||||
savedFilename = filename
|
||||
savedHeader = header
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
savedSourceUrl = resp.Request.URL.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Read the response body
|
||||
data, readBodyErr := io.ReadAll(resp.Body)
|
||||
if readBodyErr != nil {
|
||||
// Keep whatever bytes we received before the error
|
||||
partialData = append(partialData, data...)
|
||||
return fmt.Errorf("read body: %w", readBodyErr)
|
||||
}
|
||||
|
||||
// Combine with previously accumulated partial data
|
||||
var fullData []byte
|
||||
if len(partialData) > 0 {
|
||||
fullData = append(partialData, data...)
|
||||
glog.V(0).Infof("resumed reading %s, got %d + %d = %d bytes",
|
||||
sourceChunk.GetFileIdString(), len(partialData), len(data), len(fullData))
|
||||
partialData = nil
|
||||
} else {
|
||||
fullData = data
|
||||
}
|
||||
|
||||
currentFileId, uploadResult, uploadErr, _ := uploader.UploadWithRetry(
|
||||
@@ -266,20 +294,20 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
|
||||
Path: path,
|
||||
},
|
||||
&operation.UploadOption{
|
||||
Filename: filename,
|
||||
Filename: savedFilename,
|
||||
Cipher: false,
|
||||
IsInputCompressed: "gzip" == header.Get("Content-Encoding"),
|
||||
MimeType: header.Get("Content-Type"),
|
||||
IsInputCompressed: "gzip" == savedHeader.Get("Content-Encoding"),
|
||||
MimeType: savedHeader.Get("Content-Type"),
|
||||
PairMap: nil,
|
||||
RetryForever: false,
|
||||
SourceUrl: sourceUrl,
|
||||
SourceUrl: savedSourceUrl,
|
||||
},
|
||||
func(host, fileId string) string {
|
||||
fileUrl := fs.buildUploadUrl(host, fileId)
|
||||
glog.V(4).Infof("replicating %s to %s header:%+v", filename, fileUrl, header)
|
||||
glog.V(4).Infof("replicating %s to %s header:%+v", savedFilename, fileUrl, savedHeader)
|
||||
return fileUrl
|
||||
},
|
||||
resp.Body,
|
||||
util.NewBytesReader(fullData),
|
||||
)
|
||||
if uploadErr != nil {
|
||||
return fmt.Errorf("upload data: %w", uploadErr)
|
||||
@@ -291,17 +319,18 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
|
||||
eofBackoff = 0
|
||||
fileId = currentFileId
|
||||
return nil
|
||||
}, func(uploadErr error) (shouldContinue bool) {
|
||||
}, func(retryErr error) (shouldContinue bool) {
|
||||
if fs.hasSourceNewerVersion(path, sourceMtime) {
|
||||
glog.V(1).Infof("skip retrying stale source %s for %s: %v", sourceChunk.GetFileIdString(), path, uploadErr)
|
||||
glog.V(1).Infof("skip retrying stale source %s for %s: %v", sourceChunk.GetFileIdString(), path, retryErr)
|
||||
return false
|
||||
}
|
||||
if isEofError(uploadErr) {
|
||||
if isEofError(retryErr) {
|
||||
eofBackoff = nextEofBackoff(eofBackoff)
|
||||
glog.V(0).Infof("source connection interrupted while replicating %s for %s, backing off %v: %v", sourceChunk.GetFileIdString(), path, eofBackoff, uploadErr)
|
||||
glog.V(0).Infof("source connection interrupted while replicating %s for %s (%d bytes received so far), backing off %v: %v",
|
||||
sourceChunk.GetFileIdString(), path, len(partialData), eofBackoff, retryErr)
|
||||
time.Sleep(eofBackoff)
|
||||
} else {
|
||||
glog.V(0).Infof("replicate %s for %s: %v", sourceChunk.GetFileIdString(), path, uploadErr)
|
||||
glog.V(0).Infof("replicate %s for %s: %v", sourceChunk.GetFileIdString(), path, retryErr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ package source
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -18,10 +17,6 @@ import (
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
type ReplicationSource interface {
|
||||
ReadPart(part string) io.ReadCloser
|
||||
}
|
||||
|
||||
type FilerSource struct {
|
||||
grpcAddress string
|
||||
grpcDialOption grpc.DialOption
|
||||
@@ -104,14 +99,14 @@ func (fs *FilerSource) LookupFileId(ctx context.Context, part string) (fileUrls
|
||||
return
|
||||
}
|
||||
|
||||
func (fs *FilerSource) ReadPart(fileId string) (filename string, header http.Header, resp *http.Response, err error) {
|
||||
func (fs *FilerSource) ReadPart(fileId string, offset int64) (filename string, header http.Header, resp *http.Response, err error) {
|
||||
|
||||
if fs.proxyByFiler {
|
||||
filename, header, resp, err = util_http.DownloadFile("http://"+fs.address+"/?proxyChunkId="+fileId, "")
|
||||
filename, header, resp, err = util_http.DownloadFile("http://"+fs.address+"/?proxyChunkId="+fileId, "", offset)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("read part %s via filer proxy %s: %v", fileId, fs.address, err)
|
||||
glog.V(0).Infof("read part %s via filer proxy %s offset %d: %v", fileId, fs.address, offset, err)
|
||||
} else {
|
||||
glog.V(4).Infof("read part %s via filer proxy %s content-length:%s", fileId, fs.address, header.Get("Content-Length"))
|
||||
glog.V(4).Infof("read part %s via filer proxy %s offset %d content-length:%s", fileId, fs.address, offset, header.Get("Content-Length"))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -122,11 +117,11 @@ func (fs *FilerSource) ReadPart(fileId string) (filename string, header http.Hea
|
||||
}
|
||||
|
||||
for _, fileUrl := range fileUrls {
|
||||
filename, header, resp, err = util_http.DownloadFile(fileUrl, "")
|
||||
filename, header, resp, err = util_http.DownloadFile(fileUrl, "", offset)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("fail to read part %s from %s: %v", fileId, fileUrl, err)
|
||||
glog.V(0).Infof("fail to read part %s from %s offset %d: %v", fileId, fileUrl, offset, err)
|
||||
} else {
|
||||
glog.V(4).Infof("read part %s from %s content-length:%s", fileId, fileUrl, header.Get("Content-Length"))
|
||||
glog.V(4).Infof("read part %s from %s offset %d content-length:%s", fileId, fileUrl, offset, header.Get("Content-Length"))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user