mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-17 20:15:53 +00:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f88ff7a0 | ||
|
|
873eb56df6 | ||
|
|
ac996c0ad1 | ||
|
|
1155e65fc5 | ||
|
|
161310d834 | ||
|
|
42ff535511 | ||
|
|
42ff7f2b8e | ||
|
|
3e55971e0e | ||
|
|
3c6700c310 | ||
|
|
5a0be275cc | ||
|
|
cabaf9c38d | ||
|
|
ad2486919c | ||
|
|
2521c202aa | ||
|
|
b0ab4c4e1e | ||
|
|
268424a5e1 | ||
|
|
a8affc851d | ||
|
|
6da432b8bf | ||
|
|
da3b7feeae | ||
|
|
00804b32fa | ||
|
|
44657988ea | ||
|
|
2d06e2d6c1 | ||
|
|
446e6f3444 | ||
|
|
348116b2c4 | ||
|
|
5b4a707c43 | ||
|
|
d3a97f76bd | ||
|
|
cdc6976e3a | ||
|
|
921535411b | ||
|
|
651546ae74 | ||
|
|
82f0a37efe | ||
|
|
9aae0bb702 | ||
|
|
80060e1bd6 | ||
|
|
72aa17ff02 | ||
|
|
e6cf190596 | ||
|
|
ee2d98f664 | ||
|
|
18b235da0d | ||
|
|
4c81015bd3 | ||
|
|
a5ccefb661 | ||
|
|
103328e70f | ||
|
|
1562621c5e | ||
|
|
3659c369e0 | ||
|
|
e12ec94747 | ||
|
|
9e56651808 | ||
|
|
d167521270 | ||
|
|
8608a4e75d | ||
|
|
896e4dd192 | ||
|
|
84512927ab | ||
|
|
590a75b9d6 | ||
|
|
8093ecf4c8 | ||
|
|
6840371774 | ||
|
|
d67fc47a61 | ||
|
|
d0f3fad133 | ||
|
|
624a13ecc6 | ||
|
|
492e700d1f | ||
|
|
b5ee5b871e | ||
|
|
6fb21c9ec0 | ||
|
|
41260a55f6 | ||
|
|
3031ab5d97 | ||
|
|
acb44a68a9 | ||
|
|
6927de6003 | ||
|
|
613a286cde | ||
|
|
55ac46f7a9 | ||
|
|
3f8035deb7 | ||
|
|
a4c8d31cb8 | ||
|
|
6bac506842 | ||
|
|
2c031d9d63 | ||
|
|
d69f45ee83 | ||
|
|
3aaf80b33b | ||
|
|
85f04b54fd | ||
|
|
6f93f87446 | ||
|
|
73949daa98 | ||
|
|
603a1221a7 | ||
|
|
f11cecc697 |
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.38.2
|
||||
uses: github/codeql-action/init@v4.38.3
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: go
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4.38.2
|
||||
uses: github/codeql-action/autobuild@v4.38.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -49,4 +49,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.38.2
|
||||
uses: github/codeql-action/analyze@v4.38.3
|
||||
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
output: trivy-results.sarif
|
||||
exit-code: '0'
|
||||
- name: Upload Trivy scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v4.38.2
|
||||
uses: github/codeql-action/upload-sarif@v4.38.3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4.38.2
|
||||
uses: github/codeql-action/upload-sarif@v4.38.3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-${{ matrix.variant }}
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
check-latest: true
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.8.0
|
||||
uses: helm/chart-testing-action@v2.9.0
|
||||
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
@@ -95,6 +95,41 @@ jobs:
|
||||
grep -A 5 "^ lifecycle:" /tmp/s3-rollout.yaml | grep -q -- "- sleep"
|
||||
echo "S3 rollout settings render correctly"
|
||||
|
||||
echo "=== Testing the S3 config checksum is stable across renders ==="
|
||||
# s3-secret.yaml keeps its generated keys with lookup. Hashing the rendered
|
||||
# Secret drew new keys for the checksum whenever lookup found nothing (on
|
||||
# install), so the first upgrade changed the checksum and restarted the
|
||||
# filer and S3 pods although nothing had changed.
|
||||
s3_checksum() { # <template> <helm args...>: the checksum/s3config of one workload
|
||||
local template=$1; shift
|
||||
local sum
|
||||
sum=$(helm template test $CHART_DIR --show-only "$template" "$@" \
|
||||
| grep 'checksum/s3config:' | awk '{print $2}')
|
||||
test -n "$sum" || { echo "no checksum/s3config in $template" >&2; return 1; }
|
||||
echo "$sum"
|
||||
}
|
||||
s3_auth_args=(--set s3.enabled=true --set s3.enableAuth=true)
|
||||
s3_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")
|
||||
filer_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}")
|
||||
test "$s3_sum" = "$filer_sum" # one Secret, one checksum
|
||||
test "$s3_sum" = "$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")"
|
||||
s3_creds_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}" \
|
||||
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
|
||||
test "$s3_sum" != "$s3_creds_sum"
|
||||
filer_audit_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
|
||||
--set-json 'filer.s3.auditLogConfig={"fluent_port":24224}')
|
||||
test "$filer_sum" != "$filer_audit_sum"
|
||||
# Settings the Secret does not read must not restart the S3 and filer pods.
|
||||
test "$filer_sum" = "$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
|
||||
--set s3.replicas=3 --set s3.resources.limits.cpu=1)"
|
||||
all_in_one_args=(--set allInOne.enabled=true --set allInOne.s3.enabled=true --set allInOne.s3.enableAuth=true)
|
||||
aio_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")
|
||||
test "$aio_sum" = "$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")"
|
||||
aio_creds_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}" \
|
||||
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
|
||||
test "$aio_sum" != "$aio_creds_sum"
|
||||
echo "S3 config checksum is stable and follows only what the S3 Secret reads"
|
||||
|
||||
echo "=== Testing S3 credentials from an existing secret ==="
|
||||
credential_args=(
|
||||
--set s3.credentials.admin.existingSecret=minio-root
|
||||
@@ -1834,7 +1869,7 @@ jobs:
|
||||
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Create kind cluster
|
||||
uses: helm/kind-action@v1.15.0
|
||||
uses: helm/kind-action@v1.15.1
|
||||
|
||||
- name: Verify volume resize hook security contexts
|
||||
run: |
|
||||
|
||||
@@ -169,6 +169,7 @@ seaweed-volume/
|
||||
- `--memprofile`
|
||||
- `--compactionMBps` (default 0)
|
||||
- `--maintenanceMBps` (default 0)
|
||||
- `--compactionMaxParallelCommits` (default 0)
|
||||
- `--fileSizeLimitMB` (default 256)
|
||||
- `--concurrentUploadLimitMB` (default 0)
|
||||
- `--concurrentDownloadLimitMB` (default 0)
|
||||
|
||||
@@ -23,8 +23,8 @@ require (
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/facebookgo/stats v0.0.0-20151006221625-1b76add642e4
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-redsync/redsync/v4 v4.17.0
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-redsync/redsync/v4 v4.18.0
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
github.com/go-zookeeper/zk v1.0.4 // indirect
|
||||
github.com/golang/protobuf v1.5.4
|
||||
@@ -91,7 +91,7 @@ require (
|
||||
gocloud.dev/pubsub/natspubsub v0.46.0
|
||||
gocloud.dev/pubsub/rabbitpubsub v0.46.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597
|
||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/net v0.61.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
@@ -116,13 +116,13 @@ require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/Jille/raft-grpc-transport v1.6.1
|
||||
github.com/ThreeDotsLabs/watermill v1.5.2
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/a-h/templ v0.3.1070
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.2
|
||||
github.com/apache/iceberg-go v0.7.0
|
||||
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.47.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.1
|
||||
@@ -157,7 +157,7 @@ require (
|
||||
github.com/twmb/avro v1.9.0
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.3
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.155.1
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1
|
||||
go.uber.org/atomic v1.12.0
|
||||
golang.org/x/sync v0.24.0
|
||||
@@ -172,6 +172,7 @@ require (
|
||||
cloud.google.com/go/pubsub/v2 v2.6.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
|
||||
github.com/ProtonMail/gopenpgp/v3 v3.4.1 // indirect
|
||||
@@ -293,10 +294,10 @@ require (
|
||||
cloud.google.com/go/iam v1.12.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.30.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.2
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
|
||||
@@ -313,7 +314,7 @@ require (
|
||||
github.com/ProtonMail/go-srp v0.0.7 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.12.0 // indirect
|
||||
github.com/abbot/go-http-auth v0.4.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.6 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
|
||||
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect
|
||||
@@ -332,7 +333,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1
|
||||
github.com/aws/smithy-go v1.28.1
|
||||
github.com/aws/smithy-go v1.28.5
|
||||
github.com/boltdb/bolt v1.3.1 // indirect
|
||||
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||
github.com/buengese/sgzip v0.1.1 // indirect
|
||||
|
||||
@@ -34,8 +34,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.2 h1:utpeoEeZjd+A8J41zvoLsOOrqXHhX1Kx/X/tCW9dEYQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.2/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
|
||||
@@ -48,10 +48,12 @@ github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGlu
|
||||
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2 h1:tQJdgQjbKKffEiMOKJmcQPkY01eFHRqWIZVefqP44nI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2/go.mod h1:kTkpJchXAdO7LGkKZP97a+Ou8TgaF4NT6MHwOIy5uSQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 h1:cuiKf1UVyWHu+XSQghPZR/qEF43JIcuk2CDqMlPiT6M=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0/go.mod h1:9JSyvgXLPAOC4jfhgZg58XeU2FJHsGmxuYSclCFQ4ZY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 h1:X/bTwe0qpvmvPPutc+LWo7T4cKMqDP9JHP4k+Co9ZB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0/go.mod h1:+kX7ahyFelQ42F3RScM2CFgHJljp0phc/FieKLYVpFI=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
@@ -118,8 +120,8 @@ github.com/ThreeDotsLabs/watermill v1.5.2 h1:0ES33Eq1jEsP/pWvtE4n8bE0bs+9Jq7boT7
|
||||
github.com/ThreeDotsLabs/watermill v1.5.2/go.mod h1:i9/968UriGphWfEbfMuYSD1qFbYRjb0mE0r+rV0FPp4=
|
||||
github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb h1:wumPkzt4zaxO4rHPBrjDK8iZMR41C1qs7njNqlacwQg=
|
||||
github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb/go.mod h1:QiYsIBRQEO+Z4Rz7GoI+dsHVneZNONvhczuA+llOZNM=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/a-h/templ v0.3.1070 h1:3iCOXHXqFXa99OQzgkUOLJbNzv/QjFGLwKZkBAg+ADA=
|
||||
github.com/a-h/templ v0.3.1070/go.mod h1:iIqB3g2RHBe/WlnPwa+Bvhh1TKXHWLRnBD9NWHOLm78=
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
|
||||
github.com/aalpar/deheap v1.1.2 h1:MABHLcnjqsffb8GLkUFDigqpBBxOMz0DoKM9QfELeTw=
|
||||
@@ -142,8 +144,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/anchore/go-lzo v0.1.1 h1:IwL/fvkdtlIrYIXck6WxZ3nb8WjjHziYYmGxlooyOnM=
|
||||
github.com/anchore/go-lzo v0.1.1/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
|
||||
github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
|
||||
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
@@ -171,8 +173,8 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ
|
||||
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||
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.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
|
||||
@@ -213,8 +215,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/S
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
|
||||
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k=
|
||||
github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bazelbuild/rules_go v0.46.0 h1:CTefzjN/D3Cdn3rkrM6qMWuQj59OBcuOjyIp3m4hZ7s=
|
||||
@@ -462,8 +464,8 @@ github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o
|
||||
github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
|
||||
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -527,8 +529,8 @@ github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOr
|
||||
github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-redsync/redsync/v4 v4.17.0 h1:FFJ+uxZs44y4Sq10//IFKic9T94AYl+u3Sog98AHzBo=
|
||||
github.com/go-redsync/redsync/v4 v4.17.0/go.mod h1:CKVA6qwT07S/916i+Yd9h1/8YFQhCCpPYTQhvvYytJo=
|
||||
github.com/go-redsync/redsync/v4 v4.18.0 h1:DDM09PEhmNTf/vziolf2x6391y1hHxF5vQ4ytHl9xQc=
|
||||
github.com/go-redsync/redsync/v4 v4.18.0/go.mod h1:yuoqcQ55FS1VypSeVTMqFsJqTdWpEH5eA4MQGKJ9c4M=
|
||||
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||
@@ -1063,10 +1065,10 @@ github.com/rdleal/intervalst v1.5.0 h1:SEB9bCFz5IqD1yhfH1Wv8IBnY/JQxDplwkxHjT6ha
|
||||
github.com/rdleal/intervalst v1.5.0/go.mod h1:xO89Z6BC+LQDH+IPQQw/OESt5UADgFD41tYMUINGpxQ=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/redis/rueidis v1.0.76 h1:RdDWuvlYBSp+bTrBvaXqJnNEL3VVzsnjo+0psPFgLc4=
|
||||
github.com/redis/rueidis v1.0.76/go.mod h1:UsfHPSbomB6QAVMk4iiFkzRy0nh9o7scDGa+SitvBY4=
|
||||
github.com/redis/rueidis/rueidiscompat v1.0.76 h1:7LikbiqCQqCsZXeZ+akgZMnjIV/J0VHih9PIX4gGZC4=
|
||||
github.com/redis/rueidis/rueidiscompat v1.0.76/go.mod h1:UatQQLVj4QMIsZtpvRWY28qm6r2d72idhcS+C/RM+Zg=
|
||||
github.com/redis/rueidis v1.0.77 h1:ZR41bgJcm7oRFb3aSDPrRhC0eonDSrPzjvvZvHIlNjM=
|
||||
github.com/redis/rueidis v1.0.77/go.mod h1:L8mnCQJJaSNL6I4pIR6Rz732HTGS9vmuXm0yT9dRvjo=
|
||||
github.com/redis/rueidis/rueidiscompat v1.0.77 h1:S1xiLQsFv8XQ46e0LelC8ihGqbDzVRocXXZLzVAeh64=
|
||||
github.com/redis/rueidis/rueidiscompat v1.0.77/go.mod h1:aXCvRVUTvTHZY0owHuz9JjveO1hyuzyDWrpJh8/E3Ds=
|
||||
github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM=
|
||||
github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c=
|
||||
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
|
||||
@@ -1317,8 +1319,8 @@ github.com/ydb-platform/ydb-go-genproto v0.0.0-20260810122915-65bfd5c4b705 h1:7V
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260810122915-65bfd5c4b705/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.3 h1:A48PcmIETWwbEMIGwndv52uME6J1eJ28MBgF7HVR74o=
|
||||
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.3/go.mod h1:oRGF+67GWLbtNzqGygsPIUsjXmsRXD1KS5m3o5ppm2k=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1 h1:T+fB2ZDHpYIGC7DWjK+rzZHLoexBF0zG/n3Q9DGNskY=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1/go.mod h1:dJXJ1u00IqO8Vsph8fWmYj8b1E7jyphnvJPqA69emBY=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.155.1 h1:ZMByYe53zZ9R1o23fxJsbNhjzKFQkzpXm6uGj8gWHXQ=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.155.1/go.mod h1:dJXJ1u00IqO8Vsph8fWmYj8b1E7jyphnvJPqA69emBY=
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.5 h1:qYIeU1N6a92FaDA0+/vR55l5IzRF50MeLZwUWwvy2xQ=
|
||||
github.com/ydb-platform/ydb-go-yc v0.12.5/go.mod h1:DlSltVQKEdXJotaAYwoHzMGDF9EJw5S3hg3LXe/5yYw=
|
||||
github.com/ydb-platform/ydb-go-yc-metadata v0.6.2 h1:CZFawwHjLcjsxcl/g0dGIXbI2r41okPGLCydF4qTHcc=
|
||||
@@ -1447,8 +1449,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
|
||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
|
||||
@@ -43,7 +43,7 @@ spec:
|
||||
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
|
||||
{{- else }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
|
||||
{{- end }}
|
||||
{{- $_ := set $podAnnotations "checksum/master-config" (include (print .Template.BasePath "/master/master-configmap.yaml") . | sha256sum) }}
|
||||
annotations:
|
||||
|
||||
@@ -48,7 +48,7 @@ spec:
|
||||
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.filer.s3.existingConfigSecret) | default dict }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
|
||||
{{- else }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml $podAnnotations | nindent 8 }}
|
||||
|
||||
@@ -44,7 +44,7 @@ spec:
|
||||
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
|
||||
{{- else }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
|
||||
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml $podAnnotations | nindent 8 }}
|
||||
|
||||
@@ -692,3 +692,28 @@ Usage: {{ include "seaweedfs.service.loadBalancerFields" .Values.s3.service }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Checksum that restarts the S3-serving pods when the generated S3 config changes. It hashes the
|
||||
values s3-secret.yaml is built from, not the rendered Secret: that template keeps its generated keys
|
||||
with lookup, so rendering it again for a checksum draws new random keys whenever lookup finds
|
||||
nothing (on install), and the first upgrade then changed the checksum and restarted the pods
|
||||
although nothing had changed. Only the values that template reads are hashed, so scaling the S3
|
||||
gateway or changing its resources does not restart the filer. Keep this list in step with
|
||||
s3-secret.yaml.
|
||||
Keys the chart generated are not part of it. Deleting the Secret to rotate them regenerates them on
|
||||
the next upgrade without restarting the pods; restart them afterwards (kubectl rollout restart).
|
||||
Folding the stored keys in would bring the first-upgrade restart back, since lookup finds nothing
|
||||
on install.
|
||||
*/}}
|
||||
{{- define "seaweedfs.s3.configChecksum" -}}
|
||||
{{- dict
|
||||
"secretName" (include "seaweedfs.fullname" .)
|
||||
"enabled" (list .Values.s3.enabled .Values.filer.s3.enabled .Values.allInOne.enabled .Values.allInOne.s3.enabled)
|
||||
"enableAuth" (list .Values.s3.enableAuth .Values.filer.s3.enableAuth .Values.allInOne.s3.enableAuth)
|
||||
"existingConfigSecret" (list .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret .Values.allInOne.s3.existingConfigSecret)
|
||||
"credentials" .Values.s3.credentials
|
||||
"legacySecret" (list .Values.s3.reuseLegacySecret .Values.s3.legacySecretName)
|
||||
"auditLogConfig" (list .Values.s3.auditLogConfig .Values.filer.s3.auditLogConfig)
|
||||
| toJson | sha256sum -}}
|
||||
{{- end -}}
|
||||
|
||||
+1071
-1059
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
Generated
+15
-8
@@ -234,9 +234,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -244,14 +244,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2677,6 +2678,12 @@ dependencies = [
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -3389,9 +3396,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3436,9 +3443,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
@@ -64,6 +64,7 @@ seaweed-volume --port 8080 --master localhost:9333 --dir /data/vol1 --max 7 \
|
||||
| `--whiteList` | | Comma-separated IPs with write permission |
|
||||
| `--preStopSeconds` | `10` | Graceful drain period before shutdown |
|
||||
| `--compactionMBps` | `0` (unlimited) | Compaction I/O rate limit |
|
||||
| `--compactionMaxParallelCommits` | `0` (unlimited) | Max concurrent vacuum commits |
|
||||
| `--pprof` | `false` | Enable pprof HTTP handlers |
|
||||
|
||||
Set `RUST_LOG=debug` (or `trace`, `info`, `warn`) for log level control.
|
||||
|
||||
@@ -125,6 +125,11 @@ pub struct Cli {
|
||||
#[arg(long = "maintenanceMBps", default_value_t = 0)]
|
||||
pub maintenance_mb_per_second: u32,
|
||||
|
||||
/// Limit how many vacuum commits run at once on this server; queued
|
||||
/// commits hold no volume lock. 0 means unlimited.
|
||||
#[arg(long = "compactionMaxParallelCommits", default_value_t = 0)]
|
||||
pub max_parallel_vacuum_commits: usize,
|
||||
|
||||
/// Limit file size to avoid out of memory.
|
||||
#[arg(long = "fileSizeLimitMB", default_value_t = 256)]
|
||||
pub file_size_limit_mb: u32,
|
||||
@@ -263,6 +268,8 @@ pub struct VolumeServerConfig {
|
||||
pub mem_profile: String,
|
||||
pub compaction_byte_per_second: i64,
|
||||
pub maintenance_byte_per_second: i64,
|
||||
/// Max concurrent vacuum commits; 0 means unlimited.
|
||||
pub max_parallel_vacuum_commits: usize,
|
||||
pub file_size_limit_bytes: i64,
|
||||
pub concurrent_upload_limit: i64,
|
||||
pub concurrent_download_limit: i64,
|
||||
@@ -927,6 +934,7 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
|
||||
mem_profile: cli.mem_profile,
|
||||
compaction_byte_per_second: cli.compaction_mb_per_second as i64 * 1024 * 1024,
|
||||
maintenance_byte_per_second: cli.maintenance_mb_per_second as i64 * 1024 * 1024,
|
||||
max_parallel_vacuum_commits: cli.max_parallel_vacuum_commits,
|
||||
file_size_limit_bytes: cli.file_size_limit_mb as i64 * 1024 * 1024,
|
||||
concurrent_upload_limit: cli.concurrent_upload_limit_mb as i64 * 1024 * 1024,
|
||||
concurrent_download_limit: cli.concurrent_download_limit_mb as i64 * 1024 * 1024,
|
||||
|
||||
@@ -340,6 +340,14 @@ async fn run(
|
||||
rack: config.rack.clone(),
|
||||
file_size_limit_bytes: config.file_size_limit_bytes,
|
||||
maintenance_byte_per_second: config.maintenance_byte_per_second,
|
||||
vacuum_commit_slots: if config.max_parallel_vacuum_commits > 0 {
|
||||
Some(tokio::sync::Semaphore::new(
|
||||
config.max_parallel_vacuum_commits,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
// Go sets isHeartbeating: true unconditionally at startup
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
||||
has_master: !config.masters.is_empty(),
|
||||
|
||||
@@ -397,6 +397,7 @@ mod tests {
|
||||
mem_profile: String::new(),
|
||||
compaction_byte_per_second: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
max_parallel_vacuum_commits: 0,
|
||||
file_size_limit_bytes: 0,
|
||||
concurrent_upload_limit: 0,
|
||||
concurrent_download_limit: 0,
|
||||
|
||||
@@ -53,6 +53,29 @@ fn needle_not_found(needle_id: NeedleId) -> Status {
|
||||
Status::not_found(format!("needle not found {}", needle_id.0))
|
||||
}
|
||||
|
||||
/// Deadline from the caller's `grpc-timeout` header (TimeoutValue +
|
||||
/// H|M|S|m|u|n unit, per the gRPC-over-HTTP2 spec). Tonic's GrpcTimeout layer
|
||||
/// drops an over-deadline handler future anyway; parsing it here lets a queued
|
||||
/// vacuum commit refuse politely instead of being dropped mid-wait.
|
||||
fn grpc_timeout_deadline<T>(request: &Request<T>) -> Option<tokio::time::Instant> {
|
||||
let value = request.metadata().get("grpc-timeout")?.to_str().ok()?;
|
||||
let (digits, unit) = value.split_at(value.len().checked_sub(1)?);
|
||||
if digits.len() > 8 {
|
||||
return None;
|
||||
}
|
||||
let n: u64 = digits.parse().ok()?;
|
||||
let dur = match unit {
|
||||
"H" => std::time::Duration::from_secs(n * 60 * 60),
|
||||
"M" => std::time::Duration::from_secs(n * 60),
|
||||
"S" => std::time::Duration::from_secs(n),
|
||||
"m" => std::time::Duration::from_millis(n),
|
||||
"u" => std::time::Duration::from_micros(n),
|
||||
"n" => std::time::Duration::from_nanos(n),
|
||||
_ => return None,
|
||||
};
|
||||
Some(tokio::time::Instant::now() + dur)
|
||||
}
|
||||
|
||||
fn unix_now_seconds() -> f64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -986,7 +1009,12 @@ impl VolumeServer for VolumeGrpcService {
|
||||
let original_cookie = n.cookie;
|
||||
if !is_ec_volume {
|
||||
let store = self.state.store.read().unwrap();
|
||||
match store.read_volume_needle(file_id.volume_id, &mut n) {
|
||||
let mut read_option = crate::server::handlers::delete_read_option();
|
||||
match store.read_volume_needle_with_option(
|
||||
file_id.volume_id,
|
||||
&mut n,
|
||||
&mut read_option,
|
||||
) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
results.push(volume_server_pb::DeleteResult {
|
||||
@@ -1257,8 +1285,12 @@ impl VolumeServer for VolumeGrpcService {
|
||||
) -> Result<Response<volume_server_pb::VacuumVolumeCommitResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let deadline = grpc_timeout_deadline(&request);
|
||||
let vid = VolumeId(request.into_inner().volume_id);
|
||||
|
||||
let _permit = self.state.acquire_vacuum_commit_slot(deadline).await?;
|
||||
self.state.check_maintenance()?;
|
||||
|
||||
// Match Go's store_vacuum.go CommitCompactVolume: skip commit if stopping
|
||||
if *self.state.is_stopping.read().unwrap() {
|
||||
return Err(Status::internal(format!(
|
||||
@@ -7342,6 +7374,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
||||
has_master: false,
|
||||
pre_stop_seconds: 0,
|
||||
@@ -7456,6 +7490,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
||||
has_master: false,
|
||||
pre_stop_seconds: 0,
|
||||
@@ -7567,6 +7603,271 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn make_commit_limited_service(
|
||||
collection: &str,
|
||||
max_parallel_commits: usize,
|
||||
) -> (VolumeGrpcService, TempDir) {
|
||||
let (mut service, tmp) = make_local_service_with_volume(collection, None);
|
||||
if max_parallel_commits > 0 {
|
||||
Arc::get_mut(&mut service.state)
|
||||
.unwrap()
|
||||
.vacuum_commit_slots = Some(tokio::sync::Semaphore::new(max_parallel_commits));
|
||||
}
|
||||
(service, tmp)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn test_vacuum_commit_slots_bound_concurrent_commits() {
|
||||
async fn peak_holders(limit: usize) -> i32 {
|
||||
let (service, _tmp) = make_commit_limited_service("bound_commits", limit);
|
||||
let current = Arc::new(std::sync::atomic::AtomicI32::new(0));
|
||||
let peak = Arc::new(std::sync::atomic::AtomicI32::new(0));
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let state = service.state.clone();
|
||||
let current = current.clone();
|
||||
let peak = peak.clone();
|
||||
workers.push(tokio::spawn(async move {
|
||||
let Ok(Some(_permit)) = state.acquire_vacuum_commit_slot(None).await else {
|
||||
return;
|
||||
};
|
||||
let n = current.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
peak.fetch_max(n, Ordering::Relaxed);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
current.fetch_sub(1, Ordering::Relaxed);
|
||||
}));
|
||||
}
|
||||
for w in workers {
|
||||
w.await.unwrap();
|
||||
}
|
||||
peak.load(Ordering::Relaxed)
|
||||
}
|
||||
assert_eq!(peak_holders(1).await, 1);
|
||||
assert!(peak_holders(3).await <= 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_vacuum_commit_slots_unlimited_by_default() {
|
||||
let (service, _tmp) = make_commit_limited_service("unlimited_commits", 0);
|
||||
for i in 0..8 {
|
||||
service
|
||||
.state
|
||||
.acquire_vacuum_commit_slot(Some(
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(1),
|
||||
))
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("acquisition {i} must not wait when no limit is configured: {e}")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_vacuum_commit_slot_not_granted_to_expired_deadline() {
|
||||
let (service, _tmp) = make_commit_limited_service("expired_commit", 1);
|
||||
let slots = service.state.vacuum_commit_slots.as_ref().unwrap();
|
||||
let past = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
|
||||
for _ in 0..100 {
|
||||
let err = service
|
||||
.state
|
||||
.acquire_vacuum_commit_slot(Some(past))
|
||||
.await
|
||||
.expect_err("an expired deadline must not get a slot");
|
||||
assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
|
||||
assert_eq!(
|
||||
slots.available_permits(),
|
||||
1,
|
||||
"a refused acquisition must not keep the slot"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_vacuum_commit_deadline_while_queued() {
|
||||
let (service, _tmp) = make_commit_limited_service("queued_deadline", 1);
|
||||
let state = service.state.clone();
|
||||
let held = state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut request =
|
||||
Request::new(volume_server_pb::VacuumVolumeCommitRequest { volume_id: 1 });
|
||||
request
|
||||
.metadata_mut()
|
||||
.insert("grpc-timeout", "20m".parse().unwrap());
|
||||
let err = service
|
||||
.vacuum_volume_commit(request)
|
||||
.await
|
||||
.expect_err("the queued commit must expire at its deadline");
|
||||
assert_eq!(err.code(), tonic::Code::DeadlineExceeded);
|
||||
assert_eq!(
|
||||
state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.available_permits(),
|
||||
0,
|
||||
"a refused commit must not consume the held slot"
|
||||
);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_queued_vacuum_commit_holds_no_store_lock() {
|
||||
let (service, _tmp) = make_commit_limited_service("no_lock_commit", 1);
|
||||
service
|
||||
.state
|
||||
.store
|
||||
.write()
|
||||
.unwrap()
|
||||
.compact_volume(VolumeId(1), 0, 0, |_| true)
|
||||
.unwrap();
|
||||
let state = service.state.clone();
|
||||
let held = state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let committed = tokio::spawn(async move {
|
||||
service
|
||||
.vacuum_volume_commit(Request::new(volume_server_pb::VacuumVolumeCommitRequest {
|
||||
volume_id: 1,
|
||||
}))
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while state.vacuum_commits_waiting.load(Ordering::Relaxed) == 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the commit must pass the entry checks and wait for a slot");
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Ok(_guard) = state.store.try_read() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("store read blocked behind a queued commit");
|
||||
|
||||
drop(held);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), committed)
|
||||
.await
|
||||
.expect("queued commit did not run after the slot freed")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.available_permits(),
|
||||
1,
|
||||
"the commit must return its slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_vacuum_commit_releases_slot_on_error() {
|
||||
let (service, _tmp) = make_commit_limited_service("commit_err", 1);
|
||||
service
|
||||
.vacuum_volume_commit(Request::new(volume_server_pb::VacuumVolumeCommitRequest {
|
||||
volume_id: 42,
|
||||
}))
|
||||
.await
|
||||
.expect_err("committing an unknown volume must fail");
|
||||
assert_eq!(
|
||||
service
|
||||
.state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.available_permits(),
|
||||
1,
|
||||
"a failed commit must return its slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_queued_vacuum_commit_does_not_start_in_maintenance() {
|
||||
let (service, _tmp) = make_commit_limited_service("maint_commit", 1);
|
||||
service
|
||||
.state
|
||||
.store
|
||||
.write()
|
||||
.unwrap()
|
||||
.compact_volume(VolumeId(1), 0, 0, |_| true)
|
||||
.unwrap();
|
||||
let revision_before = {
|
||||
let store = service.state.store.read().unwrap();
|
||||
let (_, v) = store.find_volume(VolumeId(1)).unwrap();
|
||||
v.super_block.compaction_revision
|
||||
};
|
||||
let state = service.state.clone();
|
||||
let held = state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let committed = tokio::spawn(async move {
|
||||
service
|
||||
.vacuum_volume_commit(Request::new(volume_server_pb::VacuumVolumeCommitRequest {
|
||||
volume_id: 1,
|
||||
}))
|
||||
.await
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while state.vacuum_commits_waiting.load(Ordering::Relaxed) == 0 {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the commit must pass the entry maintenance check and wait for a slot"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
|
||||
state.maintenance.store(true, Ordering::Relaxed);
|
||||
drop(held);
|
||||
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(5), committed)
|
||||
.await
|
||||
.expect("queued commit did not return after the slot freed")
|
||||
.unwrap()
|
||||
.expect_err("a queued commit must not run in maintenance mode");
|
||||
assert!(err.message().contains("maintenance mode"));
|
||||
let revision_after = {
|
||||
let store = state.store.read().unwrap();
|
||||
let (_, v) = store.find_volume(VolumeId(1)).unwrap();
|
||||
v.super_block.compaction_revision
|
||||
};
|
||||
assert_eq!(
|
||||
revision_before, revision_after,
|
||||
"the volume must not be committed"
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.vacuum_commit_slots
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.available_permits(),
|
||||
1,
|
||||
"the refused commit must return its slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_volume_incremental_copy_streams_remote_only_volume_data() {
|
||||
let (service, _tmp, shutdown_tx, dat_bytes, super_block_size, _delete_count) =
|
||||
@@ -9623,6 +9924,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
||||
has_master: false,
|
||||
pre_stop_seconds: 0,
|
||||
@@ -10997,6 +11300,50 @@ mod tests {
|
||||
(r.status, r.error.clone())
|
||||
}
|
||||
|
||||
/// A batch delete only needs the needle's cookie, which is stored in the
|
||||
/// header, so it must not read the payload.
|
||||
#[tokio::test]
|
||||
async fn batch_delete_reads_needle_meta_only() {
|
||||
use crate::storage::volume::needle_read_hook;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const ID: u64 = 0x6e7a_0b01;
|
||||
let (service, _tmp) = make_local_service_with_volume("", None);
|
||||
let data = vec![7u8; 512 * 1024];
|
||||
let mut n = Needle {
|
||||
id: NeedleId(ID),
|
||||
cookie: Cookie(0x3344),
|
||||
data: data.clone(),
|
||||
data_size: data.len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
service
|
||||
.state
|
||||
.store
|
||||
.write()
|
||||
.unwrap()
|
||||
.write_volume_needle(VolumeId(1), &mut n, false)
|
||||
.unwrap();
|
||||
|
||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||
let _hook = needle_read_hook::register(NeedleId(ID), {
|
||||
let reads = reads.clone();
|
||||
move |len| reads.lock().unwrap().push(len)
|
||||
});
|
||||
|
||||
let (status, _) = batch_delete_1(&service, ID, 0x3345, false).await;
|
||||
assert_eq!(status, 400);
|
||||
let (status, error) = batch_delete_1(&service, ID, 0x3344, false).await;
|
||||
assert_eq!(status, 202, "{error}");
|
||||
|
||||
let reads = reads.lock().unwrap().clone();
|
||||
assert!(
|
||||
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
|
||||
"a batch delete must not read the needle's payload ({} bytes): {reads:?}",
|
||||
data.len()
|
||||
);
|
||||
}
|
||||
|
||||
const GO_EC_NEEDLE_NOT_FOUND: &str =
|
||||
"locate in local ec volume: FindNeedleFromEcx: needle not found";
|
||||
|
||||
@@ -11413,6 +11760,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
||||
has_master: false,
|
||||
pre_stop_seconds: 0,
|
||||
|
||||
@@ -161,9 +161,10 @@ impl SourceReadRequest {
|
||||
|
||||
/// HEAD, a range, or a stream.
|
||||
fn served_from_source(&self, n: &Needle) -> bool {
|
||||
// HEAD is meta-only unless the payload is needed to expand a manifest.
|
||||
if self.is_head {
|
||||
return !(n.is_chunk_manifest() && !self.bypass_cm);
|
||||
// HEAD is meta-only unless the payload is needed to expand a
|
||||
// manifest or to answer an image transform's length.
|
||||
return !self.has_image_ops && !(n.is_chunk_manifest() && !self.bypass_cm);
|
||||
}
|
||||
self.direct(n) && (self.has_range || n.data_size > STREAMING_THRESHOLD)
|
||||
}
|
||||
@@ -171,8 +172,10 @@ impl SourceReadRequest {
|
||||
/// Meta first only if the needle could be served from source; its data
|
||||
/// size is below its index size.
|
||||
fn try_meta_first(&self, size: Size) -> bool {
|
||||
self.is_head
|
||||
|| (!self.has_image_ops && (self.has_range || size.0 > STREAMING_THRESHOLD as i32))
|
||||
if self.has_image_ops {
|
||||
return false;
|
||||
}
|
||||
self.is_head || self.has_range || size.0 > STREAMING_THRESHOLD as i32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3058,6 +3061,20 @@ struct DeleteResult {
|
||||
size: i64,
|
||||
}
|
||||
|
||||
/// Read option for deletes: read only the needle's header and trailer.
|
||||
///
|
||||
/// A delete needs to know that the needle exists, what its cookie is, and
|
||||
/// whether it is a chunk manifest. None of that requires the payload. If the
|
||||
/// needle turns out to be a chunk manifest, the read loads its data as well,
|
||||
/// because the delete needs the manifest to delete the chunks.
|
||||
pub(crate) fn delete_read_option() -> ReadOption {
|
||||
ReadOption {
|
||||
attempt_meta_only: true,
|
||||
must_meta_only: true,
|
||||
..ReadOption::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_handler(
|
||||
State(state): State<Arc<VolumeServerState>>,
|
||||
request: Request<Body>,
|
||||
@@ -3226,7 +3243,7 @@ pub async fn delete_handler(
|
||||
let original_cookie = cookie;
|
||||
{
|
||||
let store = state.store.read().unwrap();
|
||||
match store.read_volume_needle(vid, &mut n) {
|
||||
match store.read_volume_needle_with_option(vid, &mut n, &mut delete_read_option()) {
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
let result = DeleteResult { size: 0 };
|
||||
@@ -5268,6 +5285,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: AtomicBool::new(false),
|
||||
has_master: false,
|
||||
pre_stop_seconds: 0,
|
||||
@@ -5523,6 +5542,17 @@ mod tests {
|
||||
(status, headers, body)
|
||||
}
|
||||
|
||||
/// The public router does not accept DELETE requests, so this calls the
|
||||
/// handler directly, the same way the admin router does.
|
||||
async fn send_delete(state: &Arc<VolumeServerState>, path: &str) -> StatusCode {
|
||||
let request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
delete_handler(State(state.clone()), request).await.status()
|
||||
}
|
||||
|
||||
/// A GET whose needle read is parked must not hold the store lock: a
|
||||
/// writer, here a real append to the same volume, must get through.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -5627,6 +5657,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A delete needs the needle's cookie and its chunk-manifest flag. Both
|
||||
/// are stored in the header and trailer, so the delete must not read the
|
||||
/// payload.
|
||||
#[tokio::test]
|
||||
async fn test_delete_reads_needle_meta_only() {
|
||||
use crate::storage::volume::needle_read_hook;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const ID: u64 = 0x6e7a_0a01;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let state = volume_test_state(&tmp);
|
||||
let data: Vec<u8> = (0..512 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
let path = put_test_needle(&state, ID, &data);
|
||||
|
||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||
let _hook = needle_read_hook::register(NeedleId(ID), {
|
||||
let reads = reads.clone();
|
||||
move |len| reads.lock().unwrap().push(len)
|
||||
});
|
||||
|
||||
let wrong_cookie = format!("/1,{:x}{:08x}", ID, TEST_COOKIE + 1);
|
||||
assert_eq!(
|
||||
send_delete(&state, &wrong_cookie).await,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(send_delete(&state, &path).await, StatusCode::ACCEPTED);
|
||||
|
||||
let reads = reads.lock().unwrap().clone();
|
||||
assert!(
|
||||
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
|
||||
"a delete must not read the needle's payload ({} bytes): {reads:?}",
|
||||
data.len()
|
||||
);
|
||||
let (status, _, _) = send_read(&state, Method::GET, &path, None).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// HEAD and ranged reads keep answering from the needle meta and the
|
||||
/// data file, and a missing or wrong-cookie needle is still a 404.
|
||||
#[tokio::test]
|
||||
@@ -6105,6 +6172,29 @@ mod tests {
|
||||
assert_eq!(taken(&reads), vec![1]);
|
||||
}
|
||||
|
||||
/// A HEAD asking for an image transform needs the payload too: a
|
||||
/// meta-only read could not produce the transformed length.
|
||||
#[tokio::test]
|
||||
async fn test_head_with_image_op_reads_full() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let state = volume_test_state(&tmp);
|
||||
let png = solid_png(8, 8);
|
||||
let stored = put_test_needle(&state, 0x6e7a_0a30, &png);
|
||||
let crop = format!(
|
||||
"{}.png?crop_x1=0&crop_y1=0&crop_x2=2&crop_y2=2",
|
||||
stored
|
||||
);
|
||||
|
||||
let (get_status, _, cropped) = send_read(&state, Method::GET, &crop, None).await;
|
||||
assert_eq!(get_status, StatusCode::OK);
|
||||
assert_ne!(cropped.len(), png.len(), "the crop must change the length");
|
||||
|
||||
let (status, headers, body) = send_read(&state, Method::HEAD, &crop, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_empty());
|
||||
assert_eq!(headers[header::CONTENT_LENGTH], cropped.len().to_string());
|
||||
}
|
||||
|
||||
/// Go takes the transform extension from the URL first — including a fid
|
||||
/// suffix like /vid,fid.png — before the resolved filename's.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1485,6 +1485,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(false),
|
||||
has_master: true,
|
||||
pre_stop_seconds: 0,
|
||||
|
||||
@@ -136,6 +136,7 @@ mod tests {
|
||||
mem_profile: String::new(),
|
||||
compaction_byte_per_second: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
max_parallel_vacuum_commits: 0,
|
||||
file_size_limit_bytes: 0,
|
||||
concurrent_upload_limit: 0,
|
||||
concurrent_download_limit: 0,
|
||||
|
||||
@@ -63,6 +63,12 @@ pub struct VolumeServerState {
|
||||
pub file_size_limit_bytes: i64,
|
||||
/// Default IO rate limit for maintenance copy/replication work.
|
||||
pub maintenance_byte_per_second: i64,
|
||||
/// Bounds concurrent vacuum commits; None means unlimited. Queued commits
|
||||
/// wait on the semaphore, not on the store lock, so reads keep flowing.
|
||||
/// Mirrors Go's `vacuumCommitSlots`.
|
||||
pub vacuum_commit_slots: Option<tokio::sync::Semaphore>,
|
||||
/// Vacuum commits currently waiting for a slot.
|
||||
pub vacuum_commits_waiting: AtomicU32,
|
||||
/// Whether the server is connected to master (heartbeat active).
|
||||
pub is_heartbeating: AtomicBool,
|
||||
/// Whether master addresses are configured.
|
||||
@@ -144,6 +150,40 @@ impl VolumeServerState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for a vacuum commit slot without touching the store lock; the wait
|
||||
/// counts against the caller's `grpc-timeout`. An already-expired deadline
|
||||
/// is refused even when a slot is free, matching Go's post-acquire
|
||||
/// `ctx.Err()` check in `acquireVacuumCommitSlot`.
|
||||
pub async fn acquire_vacuum_commit_slot(
|
||||
&self,
|
||||
deadline: Option<tokio::time::Instant>,
|
||||
) -> Result<Option<tokio::sync::SemaphorePermit<'_>>, tonic::Status> {
|
||||
let Some(slots) = &self.vacuum_commit_slots else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.vacuum_commits_waiting.fetch_add(1, Ordering::Relaxed);
|
||||
let acquired = match deadline {
|
||||
Some(d) => match tokio::time::timeout_at(d, slots.acquire()).await {
|
||||
Ok(permit) => permit.map_err(|e| tonic::Status::internal(e.to_string())),
|
||||
Err(_) => Err(tonic::Status::deadline_exceeded(
|
||||
"timed out waiting for a vacuum commit slot",
|
||||
)),
|
||||
},
|
||||
None => slots
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| tonic::Status::internal(e.to_string())),
|
||||
};
|
||||
self.vacuum_commits_waiting.fetch_sub(1, Ordering::Relaxed);
|
||||
let permit = acquired?;
|
||||
if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
|
||||
return Err(tonic::Status::deadline_exceeded(
|
||||
"timed out waiting for a vacuum commit slot",
|
||||
));
|
||||
}
|
||||
Ok(Some(permit))
|
||||
}
|
||||
|
||||
/// Build the seed master set from a list of raw `host:port[.grpcPort]`
|
||||
/// addresses, normalised the same way Go's `pb.ServerAddress.ToHttpAddress`
|
||||
/// does (drop the `.grpcPort` suffix, preserve everything else).
|
||||
|
||||
@@ -206,6 +206,8 @@ mod tests {
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: AtomicBool::new(false),
|
||||
has_master: false,
|
||||
pre_stop_seconds: 0,
|
||||
|
||||
@@ -620,11 +620,20 @@ impl DiskLocation {
|
||||
return Err(v.compacting_error());
|
||||
}
|
||||
if let Some(mut v) = self.volumes.remove(&vid) {
|
||||
crate::metrics::VOLUME_GAUGE
|
||||
.with_label_values(&[&v.collection, "volume"])
|
||||
.dec();
|
||||
v.destroy(only_empty, only_garbage, keep_remote_data)?;
|
||||
Ok(())
|
||||
match v.destroy(only_empty, only_garbage, keep_remote_data) {
|
||||
Ok(()) => {
|
||||
crate::metrics::VOLUME_GAUGE
|
||||
.with_label_values(&[&v.collection, "volume"])
|
||||
.dec();
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// A failed destroy left the local files in place, so the
|
||||
// replica stays registered rather than becoming a phantom.
|
||||
self.volumes.insert(vid, v);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(VolumeError::NotFound)
|
||||
}
|
||||
@@ -641,11 +650,13 @@ impl DiskLocation {
|
||||
|
||||
for vid in vids {
|
||||
if let Some(mut v) = self.volumes.remove(&vid) {
|
||||
crate::metrics::VOLUME_GAUGE
|
||||
.with_label_values(&[&v.collection, "volume"])
|
||||
.dec();
|
||||
if let Err(e) = v.destroy(false, false, false) {
|
||||
warn!(volume_id = vid.0, error = %e, "delete collection: failed to destroy volume");
|
||||
self.volumes.insert(vid, v);
|
||||
} else {
|
||||
crate::metrics::VOLUME_GAUGE
|
||||
.with_label_values(&[&v.collection, "volume"])
|
||||
.dec();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,6 +736,20 @@ impl Store {
|
||||
vol.read_needle(n)
|
||||
}
|
||||
|
||||
/// Read a needle from a volume using the given read option.
|
||||
///
|
||||
/// On return, `read_option.is_meta_only` tells the caller whether the
|
||||
/// payload was skipped.
|
||||
pub fn read_volume_needle_with_option(
|
||||
&self,
|
||||
vid: VolumeId,
|
||||
n: &mut Needle,
|
||||
read_option: &mut ReadOption,
|
||||
) -> Result<i32, VolumeError> {
|
||||
let (_, vol) = self.find_volume(vid).ok_or(VolumeError::NotFound)?;
|
||||
vol.read_needle_with_option(n, read_option)
|
||||
}
|
||||
|
||||
/// Read a needle from a volume, optionally reading deleted needles.
|
||||
pub fn read_volume_needle_opt(
|
||||
&self,
|
||||
|
||||
@@ -2009,12 +2009,29 @@ impl Volume {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
match self.read_needle_data_at_unlocked(
|
||||
n,
|
||||
nv.offset.to_actual_offset(),
|
||||
read_size,
|
||||
read_option,
|
||||
) {
|
||||
// With `must_meta_only`, read the header and trailer first. If the
|
||||
// needle is compressed or is a chunk manifest, the caller needs its
|
||||
// data, so read the payload as well.
|
||||
//
|
||||
// A version 1 needle has no data size field and no metadata after its
|
||||
// data, so the metadata reader would treat the whole payload as
|
||||
// metadata. It is always read in full.
|
||||
let offset = nv.offset.to_actual_offset();
|
||||
// `is_meta_only` is a result: clear it in case the option is reused.
|
||||
read_option.is_meta_only = false;
|
||||
let meta_first = read_option.must_meta_only && self.version() != VERSION_1;
|
||||
let mut read = if meta_first {
|
||||
self.read_needle_meta_at_unlocked(n, offset, read_size)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if read.is_ok() && meta_first && !n.is_compressed() && !n.is_chunk_manifest() {
|
||||
read_option.is_meta_only = true;
|
||||
}
|
||||
if read.is_ok() && !read_option.is_meta_only {
|
||||
read = self.read_needle_data_at_unlocked(n, offset, read_size, read_option);
|
||||
}
|
||||
match read {
|
||||
Ok(()) => self.check_read_write_error(None),
|
||||
Err(VolumeError::Io(ref e)) => {
|
||||
self.check_read_write_error(Some(e));
|
||||
@@ -4989,13 +5006,12 @@ impl Volume {
|
||||
.get(&storage_name);
|
||||
if let Some(backend) = backend {
|
||||
if let Err(e) = backend.delete_file_blocking(&storage_key) {
|
||||
warn!(
|
||||
volume_id = self.id.0,
|
||||
storage_name,
|
||||
storage_key,
|
||||
error = %e,
|
||||
"failed to delete remote tier file during destroy"
|
||||
);
|
||||
// Stop short of local cleanup: a failed remote delete must
|
||||
// not strand a registered replica with no local files.
|
||||
return Err(VolumeError::Io(io::Error::other(format!(
|
||||
"volume {} remote object {}/{} delete failed: {}",
|
||||
self.id, storage_name, storage_key, e
|
||||
))));
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
@@ -6080,6 +6096,120 @@ mod tests {
|
||||
assert_eq!(read_n.cookie, Cookie(0x12345678));
|
||||
}
|
||||
|
||||
/// With `must_meta_only` set, the payload is skipped whatever the needle's
|
||||
/// size. This is the option a delete uses. A chunk manifest is the
|
||||
/// exception: its data is still read, because a delete needs it to find
|
||||
/// the chunks.
|
||||
#[test]
|
||||
fn test_read_needle_must_meta_only() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut v = make_test_volume(tmp.path().to_str().unwrap());
|
||||
let data = vec![7u8; 8192];
|
||||
let mut plain = Needle {
|
||||
id: NeedleId(1),
|
||||
cookie: Cookie(0x1234),
|
||||
data: data.clone(),
|
||||
data_size: data.len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
v.write_needle(&mut plain, true, false).unwrap();
|
||||
let mut manifest = Needle {
|
||||
id: NeedleId(2),
|
||||
data: b"{}".to_vec(),
|
||||
data_size: 2,
|
||||
..Needle::default()
|
||||
};
|
||||
manifest.set_is_chunk_manifest();
|
||||
v.write_needle(&mut manifest, true, false).unwrap();
|
||||
|
||||
let read = |id: u64, read_option: &mut ReadOption| {
|
||||
let mut n = Needle {
|
||||
id: NeedleId(id),
|
||||
..Needle::default()
|
||||
};
|
||||
v.read_needle_with_option(&mut n, read_option).unwrap();
|
||||
n
|
||||
};
|
||||
let meta_only = || ReadOption {
|
||||
attempt_meta_only: true,
|
||||
must_meta_only: true,
|
||||
..ReadOption::default()
|
||||
};
|
||||
|
||||
let mut read_option = meta_only();
|
||||
let n = read(1, &mut read_option);
|
||||
assert!(read_option.is_meta_only);
|
||||
assert!(n.data.is_empty(), "{} bytes of data read", n.data.len());
|
||||
assert_eq!(n.cookie, Cookie(0x1234));
|
||||
assert_eq!(n.data_size as usize, data.len());
|
||||
|
||||
// Without `must_meta_only`, the needle is read in full.
|
||||
let mut read_option = ReadOption::default();
|
||||
let n = read(1, &mut read_option);
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.data, data);
|
||||
|
||||
let mut read_option = meta_only();
|
||||
let n = read(2, &mut read_option);
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.data, b"{}");
|
||||
|
||||
// The option is reused: a metadata-only result from the first read
|
||||
// must not carry over and skip the chunk manifest's data.
|
||||
let mut read_option = meta_only();
|
||||
read(1, &mut read_option);
|
||||
assert!(read_option.is_meta_only);
|
||||
let n = read(2, &mut read_option);
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.data, b"{}");
|
||||
}
|
||||
|
||||
/// A version 1 needle is a header, the data and a checksum: it has no data
|
||||
/// size field and no metadata after the data, so the metadata reader would
|
||||
/// take the whole payload for metadata and reject anything over 128 KiB.
|
||||
/// A read with `must_meta_only` must fall back to a full read.
|
||||
#[test]
|
||||
fn test_read_needle_must_meta_only_version_1() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
let spec = VolumeSpec {
|
||||
version: VERSION_1,
|
||||
..VolumeSpec::default()
|
||||
};
|
||||
let mut v = Volume::new(dir, dir, VolumeId(1), NeedleMapKind::InMemory, &spec).unwrap();
|
||||
assert_eq!(v.version(), VERSION_1);
|
||||
|
||||
for (id, len) in [(1u64, 11usize), (2, 256 * 1024)] {
|
||||
let data: Vec<u8> = b"hello world".iter().copied().cycle().take(len).collect();
|
||||
// The server only writes version 2 and 3 records, so build the
|
||||
// version 1 record by hand.
|
||||
let size = Size(len as i32);
|
||||
let mut blob = vec![0u8; NEEDLE_HEADER_SIZE];
|
||||
Cookie(0x1234).to_bytes(&mut blob[0..COOKIE_SIZE]);
|
||||
NeedleId(id).to_bytes(&mut blob[COOKIE_SIZE..COOKIE_SIZE + NEEDLE_ID_SIZE]);
|
||||
size.to_bytes(&mut blob[COOKIE_SIZE + NEEDLE_ID_SIZE..NEEDLE_HEADER_SIZE]);
|
||||
blob.extend_from_slice(&data);
|
||||
blob.extend_from_slice(&CRC::new(&data).0.to_be_bytes());
|
||||
blob.resize(get_actual_size(size, VERSION_1) as usize, 0);
|
||||
v.write_needle_blob_and_index(NeedleId(id), &blob, size)
|
||||
.unwrap();
|
||||
|
||||
let mut n = Needle {
|
||||
id: NeedleId(id),
|
||||
..Needle::default()
|
||||
};
|
||||
let mut read_option = ReadOption {
|
||||
attempt_meta_only: true,
|
||||
must_meta_only: true,
|
||||
..ReadOption::default()
|
||||
};
|
||||
v.read_needle_with_option(&mut n, &mut read_option).unwrap();
|
||||
assert!(!read_option.is_meta_only);
|
||||
assert_eq!(n.cookie, Cookie(0x1234));
|
||||
assert_eq!(n.data, data, "{len} byte needle");
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable write goes down the same path and lands the same data; the
|
||||
/// flush is extra work, not different work.
|
||||
#[test]
|
||||
|
||||
@@ -107,6 +107,8 @@ fn build_test_state(
|
||||
rack: String::new(),
|
||||
file_size_limit_bytes: 0,
|
||||
maintenance_byte_per_second: 0,
|
||||
vacuum_commit_slots: None,
|
||||
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
|
||||
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
|
||||
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
|
||||
Generated
+11
-2
@@ -485,6 +485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -5124,7 +5125,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -5260,7 +5261,7 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5342,7 +5343,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"prometheus",
|
||||
"prost",
|
||||
"prost-types",
|
||||
@@ -6213,6 +6216,12 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -12,6 +12,10 @@ name = "seaweed_worker_core"
|
||||
# Helpers the Rust volume server needs as well. A path dependency because
|
||||
# the two trees are separate cargo workspaces with no common root manifest.
|
||||
seaweed-common = { path = "../../../seaweed-common" }
|
||||
# Already in the graph through tonic's `tls-aws-lc` feature; named here so the
|
||||
# admin token can sign with the same HMAC the Go worker uses.
|
||||
aws-lc-rs = "1"
|
||||
base64 = "0.22"
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
prost.workspace = true
|
||||
|
||||
@@ -17,6 +17,15 @@ pub struct WorkerOptions {
|
||||
/// section of security.toml. None means plaintext, which is the default the
|
||||
/// Go worker also takes when no certificates are configured.
|
||||
pub tls: Option<TlsOptions>,
|
||||
/// The shared secret the admin stream requires when the cluster configures
|
||||
/// `jwt.filer_signing.key`; mirrored from that config value. Empty means no
|
||||
/// token is attached, which only works against admins that either bound
|
||||
/// their worker listener to loopback or authenticate with mTLS instead.
|
||||
pub signing_key: Option<String>,
|
||||
/// Seconds the minted admin token lives, mirroring
|
||||
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token without an
|
||||
/// expiry, matching the Go default.
|
||||
pub signing_key_expires_after_seconds: u64,
|
||||
}
|
||||
|
||||
/// Certificates for the control stream. All three are required together: the
|
||||
@@ -44,6 +53,8 @@ impl Default for WorkerOptions {
|
||||
max_detection_concurrency: 1,
|
||||
max_execution_concurrency: 1,
|
||||
tls: None,
|
||||
signing_key: None,
|
||||
signing_key_expires_after_seconds: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod metrics;
|
||||
pub mod registry;
|
||||
pub mod senders;
|
||||
pub mod stream;
|
||||
pub mod token;
|
||||
|
||||
/// Generated plugin.proto types.
|
||||
pub mod pb {
|
||||
|
||||
@@ -176,10 +176,19 @@ async fn serve_once(
|
||||
metadata: Default::default(),
|
||||
}))?;
|
||||
|
||||
let mut inbound = client
|
||||
.worker_stream(UnboundedReceiverStream::new(rx))
|
||||
.await?
|
||||
.into_inner();
|
||||
let mut request = tonic::Request::new(UnboundedReceiverStream::new(rx));
|
||||
if let Some(token) = crate::token::admin_token(
|
||||
options.signing_key.as_deref().unwrap_or("").as_bytes(),
|
||||
options.signing_key_expires_after_seconds,
|
||||
) {
|
||||
request.metadata_mut().insert(
|
||||
"authorization",
|
||||
format!("Bearer {token}")
|
||||
.parse()
|
||||
.context("minted admin token is not valid metadata")?,
|
||||
);
|
||||
}
|
||||
let mut inbound = client.worker_stream(request).await?.into_inner();
|
||||
|
||||
let heartbeat = spawn_heartbeat(
|
||||
sender.clone(),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aws_lc_rs::hmac;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
/// Mints the HS256 Bearer token the admin control stream requires when
|
||||
/// jwt.filer_signing.key is configured, matching the SeaweedFilerAdminClaims
|
||||
/// the Go worker sends: registered claims only, no session id.
|
||||
pub fn admin_token(signing_key: &[u8], expires_after_secs: u64) -> Option<String> {
|
||||
if signing_key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let claims = if expires_after_secs > 0 {
|
||||
let exp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default()
|
||||
+ expires_after_secs;
|
||||
format!("{{\"exp\":{exp}}}")
|
||||
} else {
|
||||
"{}".to_string()
|
||||
};
|
||||
let signing_input = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}"),
|
||||
URL_SAFE_NO_PAD.encode(claims.as_bytes())
|
||||
);
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
|
||||
let signature = hmac::sign(&key, signing_input.as_bytes());
|
||||
Some(format!(
|
||||
"{signing_input}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::admin_token;
|
||||
|
||||
#[test]
|
||||
fn no_key_no_token() {
|
||||
assert!(admin_token(b"", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_token_verifies_shape() {
|
||||
let token = admin_token(b"secret", 60).unwrap();
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert!(!parts.iter().any(|p| p.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_expiry_has_empty_claims() {
|
||||
let token = admin_token(b"secret", 0).unwrap();
|
||||
let payload = token.split('.').nth(1).unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let claims = URL_SAFE_NO_PAD.decode(payload).unwrap();
|
||||
assert_eq!(claims, b"{}");
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,18 @@ struct Args {
|
||||
#[arg(long)]
|
||||
tls_server_name: Option<String>,
|
||||
|
||||
/// Shared secret for the admin control stream, the `jwt.filer_signing.key`
|
||||
/// value the Go worker reads from its config file. Required by admins that
|
||||
/// authenticate the worker stream with a signing key.
|
||||
#[arg(long, env = "WEED_FILER_SIGNING_KEY")]
|
||||
filer_signing_key: Option<String>,
|
||||
|
||||
/// Lifetime of the token minted from the signing key, mirroring
|
||||
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token with no
|
||||
/// expiry.
|
||||
#[arg(long, default_value = "0")]
|
||||
filer_signing_key_expires_after_seconds: u64,
|
||||
|
||||
/// Serve /health, /ready and /metrics on this port, the way
|
||||
/// `weed worker -metricsPort` does. Zero, the default, serves nothing.
|
||||
#[arg(long, default_value = "0", env = "WEED_METRICS_PORT")]
|
||||
@@ -131,6 +143,8 @@ async fn main() -> Result<()> {
|
||||
max_detection_concurrency: args.max_concurrency,
|
||||
max_execution_concurrency: args.max_concurrency,
|
||||
tls,
|
||||
signing_key: args.filer_signing_key,
|
||||
signing_key_expires_after_seconds: args.filer_signing_key_expires_after_seconds,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestS3Integration(t *testing.T) {
|
||||
// findAvailablePort finds an available port by binding to port 0
|
||||
|
||||
// startMiniCluster starts a weed mini instance directly without exec.
|
||||
// Extra flags (e.g. "-s3.allowDeleteBucketNotEmpty=false") can be appended via extraArgs.
|
||||
// Extra flags can be appended via extraArgs.
|
||||
func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
|
||||
// Allocate non-colliding ports (including gRPC offsets) for weed mini.
|
||||
ports := testutil.MustFreeMiniPorts(t, []string{"Master", "Volume", "Filer", "S3"})
|
||||
@@ -872,7 +872,7 @@ func TestS3DeleteBucketNotEmpty(t *testing.T) {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
cluster, err := startMiniCluster(t, "-s3.allowDeleteBucketNotEmpty=false")
|
||||
cluster, err := startMiniCluster(t)
|
||||
require.NoError(t, err)
|
||||
defer cluster.Stop()
|
||||
|
||||
|
||||
@@ -228,8 +228,8 @@ test-with-server: start-server
|
||||
# Test with different configurations
|
||||
test-versioning-with-configs: check-deps
|
||||
@echo "Testing with different S3 configurations..."
|
||||
@echo "Testing with empty folder allowed..."
|
||||
@$(WEED_BINARY) mini -s3.port=$(S3_PORT) > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid
|
||||
@echo "Testing with delete bucket not empty allowed..."
|
||||
@$(WEED_BINARY) mini -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=true > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid
|
||||
@sleep 5
|
||||
@go test -v -timeout=5m -run "TestVersioningBasicWorkflow" . || true
|
||||
@if [ -f weed-config1.pid ]; then kill -TERM $$(cat weed-config1.pid) 2>/dev/null || true; rm -f weed-config1.pid; fi
|
||||
|
||||
@@ -3,6 +3,7 @@ package sftp
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
@@ -602,6 +603,32 @@ func TestFileContent(t *testing.T) {
|
||||
sftpClient.Remove("/binary.bin")
|
||||
})
|
||||
|
||||
t.Run("WriteAtOffsetKeepsExistingBytes", func(t *testing.T) {
|
||||
file, err := sftpClient.Create("/resume.txt")
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte("hello world"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
|
||||
// Reopen without O_TRUNC and write past the start, as a resumed upload does
|
||||
file, err = sftpClient.OpenFile("/resume.txt", os.O_WRONLY)
|
||||
require.NoError(t, err)
|
||||
_, err = file.WriteAt([]byte("WORLD!"), 6)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
|
||||
readFile, err := sftpClient.Open("/resume.txt")
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(readFile)
|
||||
require.NoError(t, err)
|
||||
readFile.Close()
|
||||
|
||||
require.Equal(t, "hello WORLD!", string(content))
|
||||
|
||||
// Clean up
|
||||
sftpClient.Remove("/resume.txt")
|
||||
})
|
||||
|
||||
t.Run("EmptyFile", func(t *testing.T) {
|
||||
file, err := sftpClient.Create("/empty.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
|
||||
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
@@ -1155,6 +1156,7 @@ func (s *AdminServer) SetBucketLifecycle(bucketName string, rules []BucketLifecy
|
||||
bucketPath := filerConfig.BucketsPath + "/" + bucketName
|
||||
resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{
|
||||
LockKey: bucketPath,
|
||||
RequestId: uuid.NewString(),
|
||||
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath,
|
||||
Mutations: []*filer_pb.ObjectMutation{bucketLifecycleMutation(filerConfig.BucketsPath, bucketName, lifecycleXML)},
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
@@ -126,6 +127,7 @@ func (s *AdminServer) writeBucketPolicy(bucketName string, policyJSON []byte) er
|
||||
bucketPath := filerConfig.BucketsPath + "/" + bucketName
|
||||
resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{
|
||||
LockKey: bucketPath,
|
||||
RequestId: uuid.NewString(),
|
||||
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath,
|
||||
Mutations: []*filer_pb.ObjectMutation{bucketPolicyMutation(filerConfig.BucketsPath, bucketName, policyJSON)},
|
||||
})
|
||||
|
||||
@@ -188,6 +188,10 @@ func (s *WorkerGrpcServer) Stop() error {
|
||||
func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStreamServer) error {
|
||||
ctx := stream.Context()
|
||||
|
||||
if err := security.CheckGrpcAdminPeer(ctx, security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get client address
|
||||
address := findClientAddress(ctx)
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// JobReplicaHosts are the volume-replica hosts a job will read from.
|
||||
// Used so executor pick prefers a worker on one of those machines.
|
||||
// Sources with ShardIds are EC-cleanup rows, not encode replicas.
|
||||
func JobReplicaHosts(job *plugin_pb.JobSpec) []string {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
var addrs []string
|
||||
addrs = append(addrs, pluginworker.ReadStringListConfig(job.Parameters, "replica_servers")...)
|
||||
if src := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", "")); src != "" {
|
||||
addrs = append(addrs, src)
|
||||
}
|
||||
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err == nil {
|
||||
for _, s := range params.GetSources() {
|
||||
if s == nil || s.Node == "" || len(s.ShardIds) > 0 {
|
||||
continue
|
||||
}
|
||||
addrs = append(addrs, s.Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return util.HostKeysFromAddresses(addrs)
|
||||
}
|
||||
|
||||
// partitionByPreferredHost splits candidates into (host matches, others).
|
||||
// No preferred hosts, or no matches, returns (nil, all) so callers keep
|
||||
// the existing slots+round-robin path.
|
||||
func partitionByPreferredHost(candidates []*WorkerSession, preferredHosts []string) (pref, rest []*WorkerSession) {
|
||||
want := util.HostKeysFromAddresses(preferredHosts)
|
||||
if len(want) == 0 || len(candidates) == 0 {
|
||||
return nil, candidates
|
||||
}
|
||||
set := make(map[string]struct{}, len(want))
|
||||
for _, h := range want {
|
||||
set[h] = struct{}{}
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[util.HostKey(c.Address)]; ok {
|
||||
pref = append(pref, c)
|
||||
continue
|
||||
}
|
||||
rest = append(rest, c)
|
||||
}
|
||||
if len(pref) == 0 {
|
||||
return nil, candidates
|
||||
}
|
||||
return pref, rest
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -267,6 +269,10 @@ func (r *Plugin) Shutdown() {
|
||||
}
|
||||
|
||||
func (r *Plugin) WorkerStream(stream plugin_pb.PluginControlService_WorkerStreamServer) error {
|
||||
if err := security.CheckGrpcAdminPeer(stream.Context(), security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
@@ -609,7 +615,7 @@ func (r *Plugin) ExecuteJob(
|
||||
releaseGate := r.acquireDetectionExecutionGate(jobType, true)
|
||||
defer releaseGate()
|
||||
|
||||
executor, err := r.registry.PickExecutor(jobType)
|
||||
executor, err := r.registry.PickExecutor(jobType, JobReplicaHosts(job)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -887,6 +887,37 @@ func (r *Plugin) trackExecutionQueued(job *plugin_pb.JobSpec) {
|
||||
})
|
||||
}
|
||||
|
||||
// bindQueuedJobWorker records the home worker while the job stays pending/queued.
|
||||
func (r *Plugin) bindQueuedJobWorker(job *plugin_pb.JobSpec, workerID string) {
|
||||
if job == nil || strings.TrimSpace(job.JobId) == "" || strings.TrimSpace(workerID) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
r.jobsMu.Lock()
|
||||
tracked := r.jobs[job.JobId]
|
||||
if tracked == nil {
|
||||
r.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
if isTerminalTrackedJobState(tracked.State) {
|
||||
r.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
tracked.WorkerID = workerID
|
||||
tracked.Message = "queued on worker " + workerID
|
||||
tracked.UpdatedAt = timeToPtr(now)
|
||||
snap := cloneTrackedJob(*tracked)
|
||||
r.dirtyJobs = true
|
||||
r.jobsMu.Unlock()
|
||||
|
||||
r.persistJobDetailSnapshot(job.JobId, func(detail *TrackedJob) {
|
||||
detail.WorkerID = snap.WorkerID
|
||||
detail.Message = snap.Message
|
||||
detail.UpdatedAt = snap.UpdatedAt
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Plugin) cancelQueuedJob(job *plugin_pb.JobSpec, cause error) {
|
||||
reason := "job canceled"
|
||||
if cause != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -28,12 +29,18 @@ const (
|
||||
defaultScheduledMaxResults int32 = 1000
|
||||
defaultScheduledExecutionConcurrency = 1
|
||||
defaultScheduledPerWorkerConcurrency = 1
|
||||
maxScheduledExecutionConcurrency = 128
|
||||
defaultScheduledRetryBackoff = 5 * time.Second
|
||||
defaultClusterContextTimeout = 10 * time.Second
|
||||
defaultWaitingBacklogFloor = 8
|
||||
defaultWaitingBacklogMultiplier = 4
|
||||
maxEstimatedRuntimeCap = 8 * time.Hour
|
||||
// Hold a job on replica-host workers until their reservation load
|
||||
// reaches this; 0 disables the pin and spills immediately.
|
||||
defaultLocalQueuePin = 5
|
||||
// Wait this long after a home worker disappears before stealing
|
||||
// its queue; 0 steals immediately. Covers a reconnect blink.
|
||||
defaultOrphanRehomeGrace = 30 * time.Second
|
||||
maxScheduledExecutionConcurrency = 128
|
||||
defaultScheduledRetryBackoff = 5 * time.Second
|
||||
defaultClusterContextTimeout = 10 * time.Second
|
||||
defaultWaitingBacklogFloor = 8
|
||||
defaultWaitingBacklogMultiplier = 4
|
||||
maxEstimatedRuntimeCap = 8 * time.Hour
|
||||
// How long started jobs may drain past the run window close.
|
||||
scheduledExecutionDrainGrace = 30 * time.Minute
|
||||
)
|
||||
@@ -49,6 +56,12 @@ type schedulerPolicy struct {
|
||||
PerWorkerConcurrency int
|
||||
RetryLimit int
|
||||
ExecutorReserveBackoff time.Duration
|
||||
// LocalQueuePin is K: do not spill off replica-host workers while
|
||||
// min(load(pref)) < K. Zero is the old immediate-spill path.
|
||||
LocalQueuePin int
|
||||
// OrphanRehomeGrace is how long a missing home's queue is held
|
||||
// before steal-without-K. Zero steals as soon as the worker is gone.
|
||||
OrphanRehomeGrace time.Duration
|
||||
}
|
||||
|
||||
// laneSchedulerLoop is the main scheduling goroutine for a single lane.
|
||||
@@ -466,6 +479,8 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
|
||||
PerWorkerConcurrency: int(adminRuntime.PerWorkerExecutionConcurrency),
|
||||
RetryLimit: int(adminRuntime.RetryLimit),
|
||||
ExecutorReserveBackoff: 200 * time.Millisecond,
|
||||
LocalQueuePin: defaultLocalQueuePin,
|
||||
OrphanRehomeGrace: defaultOrphanRehomeGrace,
|
||||
}
|
||||
|
||||
if policy.DetectionInterval < r.schedulerTick {
|
||||
@@ -496,6 +511,9 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
|
||||
if policy.ExecutionTimeout < defaultScheduledExecutionTimeout {
|
||||
policy.ExecutionTimeout = defaultScheduledExecutionTimeout
|
||||
}
|
||||
if policy.LocalQueuePin < 0 {
|
||||
policy.LocalQueuePin = 0
|
||||
}
|
||||
|
||||
return policy, true, nil
|
||||
}
|
||||
@@ -813,19 +831,22 @@ func (r *Plugin) dispatchScheduledProposals(
|
||||
// whole dispatch lets manual shell operations interleave between jobs.
|
||||
jobsRequireLock := LaneRequiresLock(JobTypeLane(jobType))
|
||||
|
||||
jobQueue := make(chan *plugin_pb.JobSpec, len(proposals))
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, len(proposals))
|
||||
for index, proposal := range proposals {
|
||||
job := buildScheduledJobSpec(jobType, proposal, index)
|
||||
r.trackExecutionQueued(job)
|
||||
select {
|
||||
case <-r.shutdownCh:
|
||||
close(jobQueue)
|
||||
return 0, 0, 0
|
||||
for _, job := range jobs {
|
||||
r.cancelQueuedJob(job, errSchedulerShutdown)
|
||||
}
|
||||
return 0, 0, len(jobs)
|
||||
default:
|
||||
jobQueue <- job
|
||||
}
|
||||
job := buildScheduledJobSpec(jobType, proposal, index)
|
||||
r.trackExecutionQueued(job)
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
close(jobQueue)
|
||||
|
||||
queues := r.assignJobsToWorkerQueues(jobType, jobs, policy)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var statsMu sync.Mutex
|
||||
@@ -842,130 +863,110 @@ func (r *Plugin) dispatchScheduledProposals(
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
jobLoop:
|
||||
for job := range jobQueue {
|
||||
for {
|
||||
select {
|
||||
case <-r.shutdownCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
return
|
||||
}
|
||||
if queues.isEmpty() {
|
||||
return
|
||||
}
|
||||
|
||||
item, targetID := r.takeQueuedJob(queues, jobType, policy)
|
||||
if item == nil {
|
||||
if queues.isEmpty() {
|
||||
return
|
||||
}
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
queues.pushFront(item)
|
||||
return
|
||||
}
|
||||
|
||||
executor, release, reserveErr := r.reserveExecutorByID(jobType, policy, targetID)
|
||||
if reserveErr != nil {
|
||||
queues.pushFront(item)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.shutdownCh:
|
||||
return
|
||||
default:
|
||||
var releaseJobLock func()
|
||||
if jobsRequireLock {
|
||||
var lockErr error
|
||||
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, item.job.JobId))
|
||||
if lockErr != nil {
|
||||
release()
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: item.job.JobId,
|
||||
JobType: item.job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
releaseJobLock()
|
||||
release()
|
||||
queues.pushFront(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := r.executeScheduledJobWithExecutor(ctx, executor, item.job, clusterContext, policy)
|
||||
if releaseJobLock != nil {
|
||||
releaseJobLock()
|
||||
}
|
||||
release()
|
||||
if errors.Is(err, errExecutorAtCapacity) {
|
||||
queues.pushFront(item)
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
r.cancelQueuedJob(item.job, err)
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
|
||||
executor, release, reserveErr := r.reserveScheduledExecutor(ctx, jobType, policy)
|
||||
if reserveErr != nil {
|
||||
if ctx.Err() != nil {
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobType: jobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution reservation failed: %v", reserveErr),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
var releaseJobLock func()
|
||||
if jobsRequireLock {
|
||||
var lockErr error
|
||||
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, job.JobId))
|
||||
if lockErr != nil {
|
||||
release()
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: job.JobId,
|
||||
JobType: job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
break
|
||||
}
|
||||
// The lock may have been held by an operator for a
|
||||
// while; re-check the execution window.
|
||||
if ctx.Err() != nil {
|
||||
releaseJobLock()
|
||||
release()
|
||||
r.cancelQueuedJob(job, ctx.Err())
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
}
|
||||
|
||||
err := r.executeScheduledJobWithExecutor(ctx, executor, job, clusterContext, policy)
|
||||
if releaseJobLock != nil {
|
||||
releaseJobLock()
|
||||
}
|
||||
release()
|
||||
if errors.Is(err, errExecutorAtCapacity) {
|
||||
r.trackExecutionQueued(job)
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
r.cancelQueuedJob(job, err)
|
||||
statsMu.Lock()
|
||||
canceledCount++
|
||||
statsMu.Unlock()
|
||||
continue jobLoop
|
||||
}
|
||||
statsMu.Lock()
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: job.JobId,
|
||||
JobType: job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution failed: %v", err),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
successCount++
|
||||
errorCount++
|
||||
statsMu.Unlock()
|
||||
break
|
||||
r.appendActivity(JobActivity{
|
||||
JobID: item.job.JobId,
|
||||
JobType: item.job.JobType,
|
||||
Source: "admin_scheduler",
|
||||
Message: fmt.Sprintf("scheduled execution failed: %v", err),
|
||||
Stage: "failed",
|
||||
OccurredAt: timeToPtr(time.Now().UTC()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
successCount++
|
||||
statsMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -976,8 +977,8 @@ func (r *Plugin) dispatchScheduledProposals(
|
||||
if drainErr == nil {
|
||||
drainErr = errSchedulerShutdown
|
||||
}
|
||||
for job := range jobQueue {
|
||||
r.cancelQueuedJob(job, drainErr)
|
||||
for _, item := range queues.drain() {
|
||||
r.cancelQueuedJob(item.job, drainErr)
|
||||
canceledCount++
|
||||
}
|
||||
|
||||
@@ -988,6 +989,7 @@ func (r *Plugin) reserveScheduledExecutor(
|
||||
ctx context.Context,
|
||||
jobType string,
|
||||
policy schedulerPolicy,
|
||||
preferredHosts []string,
|
||||
) (*WorkerSession, func(), error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -1015,7 +1017,7 @@ func (r *Plugin) reserveScheduledExecutor(
|
||||
return nil, nil, fmt.Errorf("timed out waiting for executor capacity for %s", jobType)
|
||||
}
|
||||
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
executors, err := r.registry.ListExecutors(jobType, preferredHosts...)
|
||||
if err != nil {
|
||||
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
|
||||
if ctx.Err() != nil {
|
||||
@@ -1026,11 +1028,19 @@ func (r *Plugin) reserveScheduledExecutor(
|
||||
continue
|
||||
}
|
||||
|
||||
for _, executor := range executors {
|
||||
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
|
||||
if !ok {
|
||||
continue
|
||||
pref, rest := partitionByPreferredHost(executors, preferredHosts)
|
||||
lane := JobTypeLane(jobType)
|
||||
loadOf := func(e *WorkerSession) int { return r.executorReservationLoad(e, lane) }
|
||||
|
||||
if len(pref) > 0 {
|
||||
sortExecutorsByLoad(pref, loadOf)
|
||||
if executor, release, ok := r.tryReserveFirst(pref, jobType, policy); ok {
|
||||
return executor, release, nil
|
||||
}
|
||||
}
|
||||
|
||||
sortExecutorsByLoad(rest, loadOf)
|
||||
if executor, release, ok := r.tryReserveFirst(rest, jobType, policy); ok {
|
||||
return executor, release, nil
|
||||
}
|
||||
|
||||
@@ -1051,6 +1061,75 @@ func (r *Plugin) tryReserveExecutorCapacity(
|
||||
return r.tryReserveExecutorCapacityForLane(executor, jobType, policy, JobTypeLane(jobType))
|
||||
}
|
||||
|
||||
func (r *Plugin) tryReserveFirst(executors []*WorkerSession, jobType string, policy schedulerPolicy) (*WorkerSession, func(), bool) {
|
||||
for _, executor := range executors {
|
||||
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
|
||||
if ok {
|
||||
return executor, release, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (r *Plugin) reserveExecutorByID(jobType string, policy schedulerPolicy, workerID string) (*WorkerSession, func(), error) {
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return nil, nil, fmt.Errorf("executor id is empty")
|
||||
}
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, executor := range executors {
|
||||
if executor == nil || executor.WorkerID != workerID {
|
||||
continue
|
||||
}
|
||||
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
|
||||
if !ok {
|
||||
return nil, nil, errExecutorAtCapacity
|
||||
}
|
||||
return executor, release, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("executor %s is not available for %s", workerID, jobType)
|
||||
}
|
||||
|
||||
func (r *Plugin) executorReservationLoad(executor *WorkerSession, lane SchedulerLane) int {
|
||||
if executor == nil {
|
||||
return 0
|
||||
}
|
||||
load := 0
|
||||
if executor.Heartbeat != nil && executor.Heartbeat.ExecutionSlotsUsed > 0 {
|
||||
load = int(executor.Heartbeat.ExecutionSlotsUsed)
|
||||
}
|
||||
workerID := strings.TrimSpace(executor.WorkerID)
|
||||
if ls := r.lanes[lane]; ls != nil {
|
||||
ls.execMu.Lock()
|
||||
load += ls.execRes[workerID]
|
||||
ls.execMu.Unlock()
|
||||
return load
|
||||
}
|
||||
r.schedulerExecMu.Lock()
|
||||
load += r.schedulerExecReservations[workerID]
|
||||
r.schedulerExecMu.Unlock()
|
||||
return load
|
||||
}
|
||||
|
||||
func sortExecutorsByLoad(executors []*WorkerSession, load func(*WorkerSession) int) {
|
||||
sort.SliceStable(executors, func(i, j int) bool {
|
||||
if executors[i] == nil {
|
||||
return false
|
||||
}
|
||||
if executors[j] == nil {
|
||||
return true
|
||||
}
|
||||
li, lj := load(executors[i]), load(executors[j])
|
||||
if li != lj {
|
||||
return li < lj
|
||||
}
|
||||
return executors[i].WorkerID < executors[j].WorkerID
|
||||
})
|
||||
}
|
||||
|
||||
// tryReserveExecutorCapacityForLane reserves an execution slot on the
|
||||
// per-lane reservation pool so that lanes cannot starve each other.
|
||||
func (r *Plugin) tryReserveExecutorCapacityForLane(
|
||||
@@ -1577,6 +1656,8 @@ func (r *Plugin) DispatchProposals(
|
||||
ExecutionTimeout: defaultScheduledExecutionTimeout,
|
||||
RetryBackoff: defaultScheduledRetryBackoff,
|
||||
ExecutorReserveBackoff: 200 * time.Millisecond,
|
||||
LocalQueuePin: defaultLocalQueuePin,
|
||||
OrphanRehomeGrace: defaultOrphanRehomeGrace,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ func TestLoadSchedulerPolicyUsesAdminConfig(t *testing.T) {
|
||||
if policy.JobTypeMaxRuntime != 30*time.Minute {
|
||||
t.Fatalf("unexpected max runtime: got=%v", policy.JobTypeMaxRuntime)
|
||||
}
|
||||
if policy.LocalQueuePin != defaultLocalQueuePin {
|
||||
t.Fatalf("unexpected local queue pin: got=%d", policy.LocalQueuePin)
|
||||
}
|
||||
if policy.OrphanRehomeGrace != defaultOrphanRehomeGrace {
|
||||
t.Fatalf("unexpected orphan rehome grace: got=%v", policy.OrphanRehomeGrace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSchedulerPolicyUsesDescriptorDefaultsWhenConfigMissing(t *testing.T) {
|
||||
@@ -138,13 +144,13 @@ func TestReserveScheduledExecutorRespectsPerWorkerLimit(t *testing.T) {
|
||||
ExecutorReserveBackoff: time.Millisecond,
|
||||
}
|
||||
|
||||
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve executor 1: %v", err)
|
||||
}
|
||||
defer release1()
|
||||
|
||||
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve executor 2: %v", err)
|
||||
}
|
||||
@@ -266,7 +272,7 @@ func TestReserveScheduledExecutorTimesOutWhenNoExecutor(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
pluginSvc.Shutdown()
|
||||
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy)
|
||||
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected reservation shutdown error")
|
||||
}
|
||||
@@ -297,7 +303,7 @@ func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
|
||||
ExecutorReserveBackoff: 5 * time.Millisecond,
|
||||
}
|
||||
|
||||
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve executor 1: %v", err)
|
||||
}
|
||||
@@ -308,7 +314,7 @@ func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
|
||||
}
|
||||
secondReserveCh := make(chan reserveResult, 1)
|
||||
go func() {
|
||||
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
||||
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
||||
if release2 != nil {
|
||||
release2()
|
||||
}
|
||||
@@ -992,3 +998,50 @@ func TestLaneStatus_LifecycleNextDetectionSurvivesDefaultLanePrune(t *testing.T)
|
||||
status.NextDetectionAt, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveWaterfillsPreferredHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
ids := []string{"worker-a", "worker-b", "worker-c", "worker-d"}
|
||||
for _, id := range ids {
|
||||
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: id,
|
||||
Address: "10.0.0.1:9334",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 8,
|
||||
ExecutorReserveBackoff: time.Millisecond,
|
||||
LocalQueuePin: 5,
|
||||
}
|
||||
preferred := []string{"10.0.0.1:8080"}
|
||||
|
||||
seen := map[string]int{}
|
||||
var releases []func()
|
||||
defer func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 4; i++ {
|
||||
exec, release, err := pluginSvc.reserveScheduledExecutor(context.Background(), "erasure_coding", policy, preferred)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve %d: %v", i, err)
|
||||
}
|
||||
releases = append(releases, release)
|
||||
seen[exec.WorkerID]++
|
||||
}
|
||||
if len(seen) != 4 {
|
||||
t.Fatalf("want one job per worker, got %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,14 +277,24 @@ func (r *Registry) PickDetector(jobType string) (*WorkerSession, error) {
|
||||
}
|
||||
|
||||
// PickExecutor picks one executor worker for a job type.
|
||||
func (r *Registry) PickExecutor(jobType string) (*WorkerSession, error) {
|
||||
return r.pickByKind(jobType, false)
|
||||
// preferredHosts, when set, ranks workers whose Hello Address host matches a
|
||||
// replica/source host ahead of others (same-machine EC pull). Empty means the
|
||||
// previous slots+round-robin order.
|
||||
func (r *Registry) PickExecutor(jobType string, preferredHosts ...string) (*WorkerSession, error) {
|
||||
list, err := r.ListExecutors(jobType, preferredHosts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
// ListExecutors returns sorted executor candidates for one job type.
|
||||
// Ordering is by most available execution slots, then lexical worker ID.
|
||||
// The top tie group is rotated round-robin to prevent sticky assignment.
|
||||
func (r *Registry) ListExecutors(jobType string) ([]*WorkerSession, error) {
|
||||
// preferredHosts, when any worker matches, are listed first (then the rest);
|
||||
// rotation applies only inside the preferred group so a local worker is not
|
||||
// rotated away in favor of a remote one.
|
||||
func (r *Registry) ListExecutors(jobType string, preferredHosts ...string) ([]*WorkerSession, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -293,8 +303,16 @@ func (r *Registry) ListExecutors(jobType string) ([]*WorkerSession, error) {
|
||||
return nil, fmt.Errorf("no executor worker available for job_type=%s", jobType)
|
||||
}
|
||||
|
||||
sortByKind(candidates, jobType, false)
|
||||
r.rotateTopCandidatesLocked(candidates, jobType, false)
|
||||
pref, rest := partitionByPreferredHost(candidates, preferredHosts)
|
||||
if len(pref) > 0 {
|
||||
sortByKind(pref, jobType, false)
|
||||
r.rotateTopCandidatesLocked(pref, jobType, false)
|
||||
sortByKind(rest, jobType, false)
|
||||
candidates = append(pref, rest...)
|
||||
} else {
|
||||
sortByKind(candidates, jobType, false)
|
||||
r.rotateTopCandidatesLocked(candidates, jobType, false)
|
||||
}
|
||||
|
||||
out := make([]*WorkerSession, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
|
||||
@@ -401,3 +401,70 @@ func TestRegistryHasCapableWorkerIgnoresNilCapability(t *testing.T) {
|
||||
t.Fatalf("expected HasCapableWorker to return false when capability has no detect or execute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExecutorsPrefersMatchingReplicaHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-remote",
|
||||
Address: "10.87.0.4:33646",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
||||
},
|
||||
})
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-local",
|
||||
Address: "10.87.0.3:33646",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 1},
|
||||
},
|
||||
})
|
||||
r.UpdateHeartbeat("worker-remote", &plugin_pb.WorkerHeartbeat{
|
||||
WorkerId: "worker-remote", ExecutionSlotsUsed: 0, ExecutionSlotsTotal: 8,
|
||||
})
|
||||
r.UpdateHeartbeat("worker-local", &plugin_pb.WorkerHeartbeat{
|
||||
WorkerId: "worker-local", ExecutionSlotsUsed: 0, ExecutionSlotsTotal: 1,
|
||||
})
|
||||
|
||||
// Without preference the emptier remote worker wins on slots.
|
||||
plain, err := r.ListExecutors("erasure_coding")
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutors: %v", err)
|
||||
}
|
||||
if plain[0].WorkerID != "worker-remote" {
|
||||
t.Fatalf("without preference want remote first, got %s", plain[0].WorkerID)
|
||||
}
|
||||
|
||||
// Replica on :8080 of the same host as worker-local.
|
||||
pref, err := r.ListExecutors("erasure_coding", "10.87.0.3:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutors preferred: %v", err)
|
||||
}
|
||||
if pref[0].WorkerID != "worker-local" {
|
||||
t.Fatalf("preferred host want worker-local first, got %s", pref[0].WorkerID)
|
||||
}
|
||||
if pref[1].WorkerID != "worker-remote" {
|
||||
t.Fatalf("remote should still be fallback, got %s", pref[1].WorkerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobReplicaHostsFromSourcesAndReplicaServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
job := &plugin_pb.JobSpec{
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "10.87.0.1:8080"},
|
||||
},
|
||||
"replica_servers": {
|
||||
Kind: &plugin_pb.ConfigValue_StringList{
|
||||
StringList: &plugin_pb.StringList{Values: []string{"10.87.0.1:8080", "10.87.0.2:8081"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := JobReplicaHosts(job)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
// queuedJob is one scheduled spec parked on a home worker's admin-side queue.
|
||||
type queuedJob struct {
|
||||
job *plugin_pb.JobSpec
|
||||
home string
|
||||
}
|
||||
|
||||
// workerJobQueues holds per-worker FIFOs for one dispatch batch.
|
||||
// Home assignment is done once up front; run goroutines only pop/steal.
|
||||
// leftover is the homeless FIFO: replica hostnames with no currently
|
||||
// registered worker. Orphan grace does not apply here.
|
||||
type workerJobQueues struct {
|
||||
mu sync.Mutex
|
||||
q map[string][]*queuedJob
|
||||
leftover []*queuedJob
|
||||
remaining int
|
||||
missingSince map[string]time.Time
|
||||
homes map[string]struct{}
|
||||
}
|
||||
|
||||
func newWorkerJobQueues() *workerJobQueues {
|
||||
return &workerJobQueues{
|
||||
q: make(map[string][]*queuedJob),
|
||||
missingSince: make(map[string]time.Time),
|
||||
homes: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) hasHome(workerID string) bool {
|
||||
if qs == nil || workerID == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := qs.homes[workerID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) push(home string, job *plugin_pb.JobSpec) {
|
||||
if qs == nil || job == nil {
|
||||
return
|
||||
}
|
||||
item := &queuedJob{job: job, home: home}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
qs.pushLocked(item, false)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) pushFront(item *queuedJob) {
|
||||
if qs == nil || item == nil {
|
||||
return
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
qs.pushLocked(item, true)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) pushLocked(item *queuedJob, front bool) {
|
||||
qs.remaining++
|
||||
if item.home == "" {
|
||||
if front {
|
||||
qs.leftover = append([]*queuedJob{item}, qs.leftover...)
|
||||
return
|
||||
}
|
||||
qs.leftover = append(qs.leftover, item)
|
||||
return
|
||||
}
|
||||
if front {
|
||||
qs.q[item.home] = append([]*queuedJob{item}, qs.q[item.home]...)
|
||||
return
|
||||
}
|
||||
qs.q[item.home] = append(qs.q[item.home], item)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) isEmpty() bool {
|
||||
if qs == nil {
|
||||
return true
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
return qs.remaining == 0
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) queueLen(workerID string) int {
|
||||
if qs == nil {
|
||||
return 0
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
return len(qs.q[workerID])
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) leftoverLen() int {
|
||||
if qs == nil {
|
||||
return 0
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
return len(qs.leftover)
|
||||
}
|
||||
|
||||
func (qs *workerJobQueues) drain() []*queuedJob {
|
||||
if qs == nil {
|
||||
return nil
|
||||
}
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
out := make([]*queuedJob, 0, qs.remaining)
|
||||
for id, qq := range qs.q {
|
||||
out = append(out, qq...)
|
||||
qs.q[id] = nil
|
||||
}
|
||||
out = append(out, qs.leftover...)
|
||||
qs.leftover = nil
|
||||
qs.remaining = 0
|
||||
return out
|
||||
}
|
||||
|
||||
func popFront(qq []*queuedJob) (*queuedJob, []*queuedJob) {
|
||||
item := qq[0]
|
||||
if len(qq) == 1 {
|
||||
return item, nil
|
||||
}
|
||||
return item, qq[1:]
|
||||
}
|
||||
|
||||
// assignJobsToWorkerQueues parks each job on the least-loaded replica-host
|
||||
// worker. A home exists only if a currently registered worker's -address
|
||||
// hostname matches a replica host. Otherwise the job is homeless: global
|
||||
// leftover FIFO, no fake home, no K, no orphan grace. Single-threaded.
|
||||
func (r *Plugin) assignJobsToWorkerQueues(
|
||||
jobType string,
|
||||
jobs []*plugin_pb.JobSpec,
|
||||
_ schedulerPolicy,
|
||||
) *workerJobQueues {
|
||||
qs := newWorkerJobQueues()
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
lane := JobTypeLane(jobType)
|
||||
queued := make(map[string]int, len(executors))
|
||||
|
||||
for _, job := range jobs {
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
if err != nil || len(executors) == 0 {
|
||||
qs.push("", job)
|
||||
continue
|
||||
}
|
||||
pref, _ := partitionByPreferredHost(executors, JobReplicaHosts(job))
|
||||
if len(pref) == 0 {
|
||||
// Never registered for that hostname: homeless, not an orphan.
|
||||
qs.push("", job)
|
||||
continue
|
||||
}
|
||||
for _, e := range pref {
|
||||
if e != nil && e.WorkerID != "" {
|
||||
qs.homes[e.WorkerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
sortExecutorsByLoad(pref, func(e *WorkerSession) int {
|
||||
return r.executorReservationLoad(e, lane) + queued[e.WorkerID]
|
||||
})
|
||||
home := pref[0].WorkerID
|
||||
qs.push(home, job)
|
||||
queued[home]++
|
||||
r.bindQueuedJobWorker(job, home)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
// takeQueuedJob pops a job for a free worker.
|
||||
// Home worker (hostname matched a replica / has a home-queue identity):
|
||||
// 1. own home queue
|
||||
// 2. steal from another home if len>=K, or orphan past OrphanRehomeGrace
|
||||
// 3. homeless leftover FIFO
|
||||
// No-home worker (no matching replica, no home-queue identity):
|
||||
// 1. homeless leftover FIFO
|
||||
// 2. only if leftover empty: steal (K / orphan), same rules
|
||||
func (r *Plugin) takeQueuedJob(
|
||||
qs *workerJobQueues,
|
||||
jobType string,
|
||||
policy schedulerPolicy,
|
||||
) (item *queuedJob, targetID string) {
|
||||
if qs == nil {
|
||||
return nil, ""
|
||||
}
|
||||
executors, err := r.registry.ListExecutors(jobType)
|
||||
if err != nil || len(executors) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
lane := JobTypeLane(jobType)
|
||||
pin := policy.LocalQueuePin
|
||||
defer func() {
|
||||
if item != nil && targetID != "" {
|
||||
r.bindQueuedJobWorker(item.job, targetID)
|
||||
}
|
||||
}()
|
||||
|
||||
qs.mu.Lock()
|
||||
defer qs.mu.Unlock()
|
||||
if qs.remaining == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
var bestHome *WorkerSession
|
||||
bestLoad := 0
|
||||
for _, exec := range executors {
|
||||
if len(qs.q[exec.WorkerID]) == 0 {
|
||||
continue
|
||||
}
|
||||
limit := schedulerWorkerExecutionLimit(exec, jobType, policy)
|
||||
if limit <= 0 {
|
||||
continue
|
||||
}
|
||||
load := r.executorReservationLoad(exec, lane)
|
||||
if load >= limit {
|
||||
continue
|
||||
}
|
||||
if bestHome == nil || load < bestLoad || (load == bestLoad && exec.WorkerID < bestHome.WorkerID) {
|
||||
bestHome = exec
|
||||
bestLoad = load
|
||||
}
|
||||
}
|
||||
if bestHome != nil {
|
||||
id := bestHome.WorkerID
|
||||
item, qs.q[id] = popFront(qs.q[id])
|
||||
qs.remaining--
|
||||
return item, id
|
||||
}
|
||||
|
||||
var thief *WorkerSession
|
||||
for _, exec := range executors {
|
||||
limit := schedulerWorkerExecutionLimit(exec, jobType, policy)
|
||||
if limit <= 0 {
|
||||
continue
|
||||
}
|
||||
if r.executorReservationLoad(exec, lane) >= limit {
|
||||
continue
|
||||
}
|
||||
if thief == nil || exec.WorkerID < thief.WorkerID {
|
||||
thief = exec
|
||||
}
|
||||
}
|
||||
if thief == nil {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
takeLeftover := func() (*queuedJob, string) {
|
||||
if len(qs.leftover) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
item, qs.leftover = popFront(qs.leftover)
|
||||
qs.remaining--
|
||||
return item, thief.WorkerID
|
||||
}
|
||||
if !qs.hasHome(thief.WorkerID) {
|
||||
if item, id := takeLeftover(); item != nil {
|
||||
return item, id
|
||||
}
|
||||
}
|
||||
|
||||
live := make(map[string]struct{}, len(executors))
|
||||
for _, exec := range executors {
|
||||
live[exec.WorkerID] = struct{}{}
|
||||
delete(qs.missingSince, exec.WorkerID)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
grace := policy.OrphanRehomeGrace
|
||||
victimID := ""
|
||||
victimLen := 0
|
||||
victimOrphan := false
|
||||
for id, qq := range qs.q {
|
||||
if len(qq) == 0 {
|
||||
delete(qs.missingSince, id)
|
||||
continue
|
||||
}
|
||||
_, isLive := live[id]
|
||||
orphan := !isLive
|
||||
if isLive {
|
||||
if pin > 0 && len(qq) < pin {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if _, ok := qs.missingSince[id]; !ok {
|
||||
qs.missingSince[id] = now
|
||||
}
|
||||
if grace > 0 && now.Sub(qs.missingSince[id]) < grace {
|
||||
continue
|
||||
}
|
||||
}
|
||||
better := victimID == ""
|
||||
if !better && orphan != victimOrphan {
|
||||
better = orphan
|
||||
}
|
||||
if !better && orphan == victimOrphan {
|
||||
better = len(qq) > victimLen || (len(qq) == victimLen && id < victimID)
|
||||
}
|
||||
if better {
|
||||
victimID = id
|
||||
victimLen = len(qq)
|
||||
victimOrphan = orphan
|
||||
}
|
||||
}
|
||||
if victimID != "" {
|
||||
item, qs.q[victimID] = popFront(qs.q[victimID])
|
||||
qs.remaining--
|
||||
return item, thief.WorkerID
|
||||
}
|
||||
return takeLeftover()
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
func replicaJob(id, source string) *plugin_pb.JobSpec {
|
||||
return &plugin_pb.JobSpec{
|
||||
JobId: id,
|
||||
JobType: "erasure_coding",
|
||||
Summary: id,
|
||||
DedupeKey: id,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: source},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func registerECWorkers(t *testing.T, pluginSvc *Plugin, workers map[string]string) {
|
||||
t.Helper()
|
||||
for id, addr := range workers {
|
||||
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: id,
|
||||
Address: addr,
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignJobsWaterfillsPreferredWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-a": "10.0.0.1:9334",
|
||||
"worker-b": "10.0.0.2:9334",
|
||||
"worker-c": "10.0.0.3:9334",
|
||||
"worker-d": "10.0.0.4:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("j1", "10.0.0.1:8080"),
|
||||
replicaJob("j2", "10.0.0.1:8080"),
|
||||
replicaJob("j3", "10.0.0.1:8080"),
|
||||
replicaJob("j4", "10.0.0.1:8080"),
|
||||
}
|
||||
// All four replica IPs listed so every worker is preferred.
|
||||
for _, job := range jobs {
|
||||
job.Parameters["replica_servers"] = &plugin_pb.ConfigValue{
|
||||
Kind: &plugin_pb.ConfigValue_StringList{StringList: &plugin_pb.StringList{
|
||||
Values: []string{"10.0.0.1:8080", "10.0.0.2:8080", "10.0.0.3:8080", "10.0.0.4:8080"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, schedulerPolicy{LocalQueuePin: 5})
|
||||
if qs.queueLen("worker-a")+qs.queueLen("worker-b")+qs.queueLen("worker-c")+qs.queueLen("worker-d") != 4 {
|
||||
t.Fatalf("expected 4 assigned jobs")
|
||||
}
|
||||
for _, id := range []string{"worker-a", "worker-b", "worker-c", "worker-d"} {
|
||||
if qs.queueLen(id) != 1 {
|
||||
t.Fatalf("want 1 job on %s, got %d", id, qs.queueLen(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignAndTakeDoesNotStarveOtherHomes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("local-1", "10.0.0.1:8080"),
|
||||
replicaJob("local-2", "10.0.0.1:8080"),
|
||||
replicaJob("remote-1", "10.0.0.2:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 2 || qs.queueLen("worker-remote") != 1 {
|
||||
t.Fatalf("homes local=%d remote=%d", qs.queueLen("worker-local"), qs.queueLen("worker-remote"))
|
||||
}
|
||||
|
||||
_, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
_, release1, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, target1)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve 1: %v", err)
|
||||
}
|
||||
defer release1()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || target2 != "worker-remote" {
|
||||
t.Fatalf("second take should start remote home, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsOnlyWhenHomeQueueReachesK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
|
||||
}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 6 {
|
||||
t.Fatalf("all jobs should home on local, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if target != "worker-local" {
|
||||
t.Fatalf("first take want local, got %s", target)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
// 5 still queued on local (>=K) and local has no slot → steal to remote.
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("expected steal to remote, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 4 {
|
||||
t.Fatalf("after steal want 4 left on local, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
// 4 < K: do not steal again.
|
||||
if item, tgt := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
|
||||
t.Fatalf("pin should hold remaining jobs, got target=%s", tgt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsImmediatelyWhenPinDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("a", "10.0.0.1:8080"),
|
||||
replicaJob("b", "10.0.0.1:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 0}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
|
||||
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if target != "worker-local" {
|
||||
t.Fatalf("first take want local, got %s", target)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("K=0 should steal immediately, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsOrphanedHomeWithoutK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("o1", "10.0.0.1:8080"),
|
||||
replicaJob("o2", "10.0.0.1:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 2 {
|
||||
t.Fatalf("want 2 on local, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
pluginSvc.registry.Remove("worker-local")
|
||||
|
||||
if item, tgt := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
|
||||
t.Fatalf("within grace should hold orphan queue, got target=%s", tgt)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 2 {
|
||||
t.Fatalf("held queue changed: %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
qs.mu.Lock()
|
||||
qs.missingSince["worker-local"] = time.Now().Add(-time.Minute)
|
||||
qs.mu.Unlock()
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("after grace should steal without K, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 1 {
|
||||
t.Fatalf("want 1 orphan left, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
stolen2, stealTarget2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen2 == nil || stealTarget2 != "worker-remote" {
|
||||
t.Fatalf("second orphan should also move, got job=%v target=%s", stolen2, stealTarget2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeKeepsOrphanWhenHomeReconnectsDuringGrace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{replicaJob("blink", "10.0.0.1:8080")}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
pluginSvc.registry.Remove("worker-local")
|
||||
if item, _ := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
|
||||
t.Fatal("expected hold during grace")
|
||||
}
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{"worker-local": "10.0.0.1:9334"})
|
||||
item, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item == nil || target != "worker-local" {
|
||||
t.Fatalf("reconnected home should run its queue, got job=%v target=%s", item, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeStealsOrphanImmediatelyWhenGraceDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{replicaJob("o1", "10.0.0.1:8080")}
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5, OrphanRehomeGrace: 0}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
pluginSvc.registry.Remove("worker-local")
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" {
|
||||
t.Fatalf("grace=0 should steal immediately, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindQueuedJobWorkerKeepsPendingState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
job := replicaJob("bind-1", "10.0.0.1:8080")
|
||||
pluginSvc.trackExecutionQueued(job)
|
||||
pluginSvc.bindQueuedJobWorker(job, "worker-local")
|
||||
|
||||
pluginSvc.jobsMu.Lock()
|
||||
tracked := pluginSvc.jobs[job.JobId]
|
||||
pluginSvc.jobsMu.Unlock()
|
||||
if tracked == nil {
|
||||
t.Fatal("missing tracked job")
|
||||
}
|
||||
if tracked.State != "job_state_pending" {
|
||||
t.Fatalf("state=%s", tracked.State)
|
||||
}
|
||||
if tracked.Stage != "queued" {
|
||||
t.Fatalf("stage=%s", tracked.Stage)
|
||||
}
|
||||
if tracked.WorkerID != "worker-local" {
|
||||
t.Fatalf("worker=%s", tracked.WorkerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomelessAllRemoteWorkersUseFIFOWithoutPinOrGrace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
// Workers never registered on the replica hostname — homeless, not orphan.
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-a": "10.0.0.5:9334",
|
||||
"worker-b": "10.0.0.6:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("h1", "10.0.0.1:8080"),
|
||||
replicaJob("h2", "10.0.0.1:8080"),
|
||||
replicaJob("h3", "10.0.0.1:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-a") != 0 || qs.queueLen("worker-b") != 0 {
|
||||
t.Fatalf("homeless jobs must not get a fake home, a=%d b=%d", qs.queueLen("worker-a"), qs.queueLen("worker-b"))
|
||||
}
|
||||
if qs.leftoverLen() != 3 {
|
||||
t.Fatalf("want 3 homeless FIFO, got %d", qs.leftoverLen())
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || item1.job.JobId != "h1" {
|
||||
t.Fatalf("FIFO first want h1, got %v", item1)
|
||||
}
|
||||
_, release1, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, target1)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve 1: %v", err)
|
||||
}
|
||||
defer release1()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "h2" {
|
||||
t.Fatalf("FIFO second want h2 immediately, got %v", item2)
|
||||
}
|
||||
if target2 == "" || target2 == target1 {
|
||||
t.Fatalf("second take should hit the other idle worker, t1=%s t2=%s", target1, target2)
|
||||
}
|
||||
if qs.leftoverLen() != 1 {
|
||||
t.Fatalf("want 1 homeless left, got %d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixedReplicaLocalJobsStayHomeBeforeHomeless(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("local-1", "10.0.0.1:8080"),
|
||||
replicaJob("local-2", "10.0.0.1:8080"),
|
||||
replicaJob("homeless-1", "10.0.0.9:8080"),
|
||||
replicaJob("homeless-2", "10.0.0.9:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 2 || qs.queueLen("worker-remote") != 0 || qs.leftoverLen() != 2 {
|
||||
t.Fatalf("homes local=%d remote=%d leftover=%d", qs.queueLen("worker-local"), qs.queueLen("worker-remote"), qs.leftoverLen())
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || item1.job.JobId != "local-1" || target1 != "worker-local" {
|
||||
t.Fatalf("first take want local-1 on home, got job=%v target=%s", item1, target1)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "homeless-1" || target2 != "worker-remote" {
|
||||
t.Fatalf("remote should take homeless, not steal pinned home, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
if qs.queueLen("worker-local") != 1 {
|
||||
t.Fatalf("remaining replica-local job must stay home, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
if qs.leftoverLen() != 1 {
|
||||
t.Fatalf("one homeless should remain, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleHomeWorkerTakesHomelessAfterEmptyHomeQueue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
})
|
||||
|
||||
jobs := []*plugin_pb.JobSpec{
|
||||
replicaJob("home-1", "10.0.0.1:8080"),
|
||||
replicaJob("homeless-1", "10.0.0.9:8080"),
|
||||
}
|
||||
policy := schedulerPolicy{
|
||||
PerWorkerConcurrency: 1,
|
||||
LocalQueuePin: 5,
|
||||
OrphanRehomeGrace: 30 * time.Second,
|
||||
}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 1 || qs.leftoverLen() != 1 {
|
||||
t.Fatalf("home=%d leftover=%d", qs.queueLen("worker-local"), qs.leftoverLen())
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || item1.job.JobId != "home-1" || target1 != "worker-local" {
|
||||
t.Fatalf("first take want home-1, got job=%v target=%s", item1, target1)
|
||||
}
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "homeless-1" || target2 != "worker-local" {
|
||||
t.Fatalf("idle home with empty queue may take homeless, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
if qs.leftoverLen() != 0 {
|
||||
t.Fatalf("homeless should be gone, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomelessWorkerTakesFIFOBeforeStealableHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-remote": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, 7)
|
||||
for i := 0; i < 6; i++ {
|
||||
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
|
||||
}
|
||||
jobs = append(jobs, replicaJob("homeless-1", "10.0.0.9:8080"))
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 6 || qs.leftoverLen() != 1 {
|
||||
t.Fatalf("home=%d leftover=%d", qs.queueLen("worker-local"), qs.leftoverLen())
|
||||
}
|
||||
if qs.hasHome("worker-remote") {
|
||||
t.Fatal("remote must have no home identity")
|
||||
}
|
||||
|
||||
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if target != "worker-local" {
|
||||
t.Fatalf("first take want local, got %s", target)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
item, takeTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item == nil || takeTarget != "worker-remote" || item.job.JobId != "homeless-1" {
|
||||
t.Fatalf("remote must take homeless FIFO before K-steal, got job=%v target=%s", item, takeTarget)
|
||||
}
|
||||
if qs.leftoverLen() != 0 {
|
||||
t.Fatalf("homeless should be gone, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
if qs.queueLen("worker-local") != 5 {
|
||||
t.Fatalf("stealable home must stay until FIFO empty, got %d", qs.queueLen("worker-local"))
|
||||
}
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-remote" || stolen.job.JobId == "homeless-1" {
|
||||
t.Fatalf("after FIFO empty remote may steal >=K, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeWorkerStealsBeforeHomelessAfterOwnQueueEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginSvc, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer pluginSvc.Shutdown()
|
||||
|
||||
registerECWorkers(t, pluginSvc, map[string]string{
|
||||
"worker-local": "10.0.0.1:9334",
|
||||
"worker-other": "10.0.0.2:9334",
|
||||
})
|
||||
|
||||
jobs := make([]*plugin_pb.JobSpec, 0, 8)
|
||||
for i := 0; i < 6; i++ {
|
||||
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
|
||||
}
|
||||
jobs = append(jobs, replicaJob("other-1", "10.0.0.2:8080"))
|
||||
jobs = append(jobs, replicaJob("homeless-1", "10.0.0.9:8080"))
|
||||
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
|
||||
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
|
||||
if qs.queueLen("worker-local") != 6 || qs.queueLen("worker-other") != 1 || qs.leftoverLen() != 1 {
|
||||
t.Fatalf("local=%d other=%d leftover=%d", qs.queueLen("worker-local"), qs.queueLen("worker-other"), qs.leftoverLen())
|
||||
}
|
||||
if !qs.hasHome("worker-other") {
|
||||
t.Fatal("other must keep home identity")
|
||||
}
|
||||
|
||||
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item1 == nil || target1 != "worker-local" {
|
||||
t.Fatalf("first take want local home, got job=%v target=%s", item1, target1)
|
||||
}
|
||||
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve local: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if item2 == nil || item2.job.JobId != "other-1" || target2 != "worker-other" {
|
||||
t.Fatalf("other should drain own home first, got job=%v target=%s", item2, target2)
|
||||
}
|
||||
|
||||
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
|
||||
if stolen == nil || stealTarget != "worker-other" || stolen.job.JobId == "homeless-1" {
|
||||
t.Fatalf("home worker with empty own queue still steals before homeless, got job=%v target=%s", stolen, stealTarget)
|
||||
}
|
||||
if qs.leftoverLen() != 1 {
|
||||
t.Fatalf("homeless should still wait, leftover=%d", qs.leftoverLen())
|
||||
}
|
||||
}
|
||||
@@ -396,11 +396,7 @@ window.Dashboard = {
|
||||
// Initialize event handlers
|
||||
function initializeEventHandlers() {
|
||||
// S3 Bucket Management
|
||||
const createBucketForm = document.getElementById('createBucketForm');
|
||||
if (createBucketForm) {
|
||||
createBucketForm.addEventListener('submit', handleCreateBucket);
|
||||
}
|
||||
|
||||
|
||||
// Delete bucket buttons
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.delete-bucket-btn')) {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,174 @@
|
||||
package topology
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
// A master that answered a capacity question for a disk type a node lacks
|
||||
// published an empty DiskInfo of that type for the node: no capacity, no
|
||||
// volumes, DiskId 0 -- the same id as the node's real first disk. Disks are
|
||||
// keyed by node and disk id here, so whichever DiskInfo the map iteration
|
||||
// visited last won disk 0: an SSD-only node sometimes looked like an empty
|
||||
// HDD disk (and a balance destination for HDD volumes), and an HDD node
|
||||
// sometimes lost its volumes behind an empty "ssd" disk.
|
||||
func TestUpdateTopologyIgnoresEmptyDiskTypeEntries(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "phantom_disk_types",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "ssd-node",
|
||||
Address: "ssd-node:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"ssd": {Type: "ssd", MaxVolumeCount: 200, VolumeCount: 1,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7, DiskType: "ssd"}}},
|
||||
"": {Type: ""}, // the empty entry the master made up
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "hdd-node",
|
||||
Address: "hdd-node:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {Type: "", MaxVolumeCount: 100, VolumeCount: 1,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3}}},
|
||||
"ssd": {Type: "ssd"},
|
||||
},
|
||||
},
|
||||
{
|
||||
// An empty disk of unknown capacity with nothing else on the
|
||||
// node is a disk, not a leftover: it must stay.
|
||||
Id: "new-node",
|
||||
Address: "new-node:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"": {Type: ""}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
at := NewActiveTopology(0)
|
||||
// Map iteration order is random; repeat so a shadowed disk would show.
|
||||
for i := 0; i < 64; i++ {
|
||||
if err := at.UpdateTopology(topo); err != nil {
|
||||
t.Fatalf("UpdateTopology: %v", err)
|
||||
}
|
||||
for node, want := range map[string]string{"ssd-node": "ssd", "hdd-node": "", "new-node": ""} {
|
||||
disks := at.GetNodeDisks(node)
|
||||
if len(disks) != 1 || disks[0].DiskType != want || (node != "new-node" && disks[0].DiskInfo.MaxVolumeCount == 0) {
|
||||
var got []string
|
||||
for _, d := range disks {
|
||||
got = append(got, "\""+d.DiskType+"\"")
|
||||
}
|
||||
t.Fatalf("pass %d: %s disks %v, want exactly its real %q disk", i, node, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, _, disks := CountTopologyResources(topo); disks != 3 {
|
||||
t.Errorf("CountTopologyResources disks = %d, want 3 (a made-up empty entry is not a disk)", disks)
|
||||
}
|
||||
}
|
||||
|
||||
// The volume index is built from the per-disk map, so a shadowed disk 0 also
|
||||
// hid its volumes: planners failed to find a volume on the server that holds
|
||||
// it ("Could not find volume N on source server").
|
||||
func TestUpdateTopologyKeepsVolumesBehindEmptyDiskTypeEntries(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "phantom_disk_types_index",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{{
|
||||
Id: "hdd-node",
|
||||
Address: "hdd-node:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {Type: "", MaxVolumeCount: 100, VolumeCount: 1,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3, Collection: "c1"}}},
|
||||
"ssd": {Type: "ssd"},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
at := NewActiveTopology(0)
|
||||
for i := 0; i < 64; i++ {
|
||||
if err := at.UpdateTopology(topo); err != nil {
|
||||
t.Fatalf("UpdateTopology: %v", err)
|
||||
}
|
||||
locations := at.GetVolumeLocations(3, "c1")
|
||||
if len(locations) != 1 || locations[0].ServerID != "hdd-node" {
|
||||
t.Fatalf("pass %d: volume 3 locations %+v, want it on hdd-node", i, locations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The made-up entry always carries DiskId 0, so it need not collide with a
|
||||
// real disk: here each node's only usable disk is location 1 (location 0 is
|
||||
// unavailable, so the volume server does not report it). The entry must still
|
||||
// not count as a disk, nor reach planners as an empty disk of unknown
|
||||
// capacity on a node that has no disk of its type.
|
||||
func TestUpdateTopologyIgnoresEmptyDiskTypeEntriesAtAnyDiskId(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "phantom_disk_types_no_collision",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "ssd-node",
|
||||
Address: "ssd-node:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"ssd": {Type: "ssd", DiskId: 1, MaxVolumeCount: 200, VolumeCount: 1,
|
||||
MaxVolumeCountByDisk: map[uint32]int64{1: 200},
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 7, DiskType: "ssd", DiskId: 1}}},
|
||||
"": {Type: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "hdd-node",
|
||||
Address: "hdd-node:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {Type: "", DiskId: 1, MaxVolumeCount: 100, VolumeCount: 1,
|
||||
MaxVolumeCountByDisk: map[uint32]int64{1: 100},
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{{Id: 3, DiskId: 1}}},
|
||||
"ssd": {Type: "ssd"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
at := NewActiveTopology(0)
|
||||
for i := 0; i < 16; i++ {
|
||||
if err := at.UpdateTopology(topo); err != nil {
|
||||
t.Fatalf("UpdateTopology: %v", err)
|
||||
}
|
||||
for node, want := range map[string]string{"ssd-node": "ssd", "hdd-node": ""} {
|
||||
disks := at.GetNodeDisks(node)
|
||||
if len(disks) != 1 || disks[0].DiskType != want || disks[0].DiskID != 1 {
|
||||
var got []string
|
||||
for _, d := range disks {
|
||||
got = append(got, fmt.Sprintf("%q@%d", d.DiskType, d.DiskID))
|
||||
}
|
||||
t.Fatalf("pass %d: %s disks %v, want only its real %q disk 1", i, node, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, disk := range at.GetDisksWithEffectiveCapacity(TaskTypeBalance, "", 1) {
|
||||
if disk.DiskID == 0 {
|
||||
t.Errorf("planners offered %s disk 0 (%q), which does not exist", disk.NodeID, disk.DiskType)
|
||||
}
|
||||
}
|
||||
if _, _, disks := CountTopologyResources(topo); disks != 2 {
|
||||
t.Errorf("CountTopologyResources disks = %d, want 2", disks)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,36 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
)
|
||||
|
||||
// isEmptyDiskTypeEntry reports a DiskInfo with no capacity and nothing on it.
|
||||
func isEmptyDiskTypeEntry(diskInfo *master_pb.DiskInfo) bool {
|
||||
return diskInfo != nil &&
|
||||
diskInfo.MaxVolumeCount <= 0 &&
|
||||
diskInfo.VolumeCount == 0 &&
|
||||
diskInfo.RemoteVolumeCount == 0 &&
|
||||
len(diskInfo.VolumeInfos) == 0 &&
|
||||
len(diskInfo.EcShardInfos) == 0 &&
|
||||
len(diskInfo.MaxVolumeCountByDisk) == 0
|
||||
}
|
||||
|
||||
// IsPhantomDiskTypeEntry reports whether node's DiskInfo of diskType is an
|
||||
// empty entry on a node that has a disk of another type with capacity or
|
||||
// data. Masters used to publish such an entry for every disk type a capacity
|
||||
// check had asked about; keyed by node and disk id it can replace or pose as
|
||||
// a real disk and be offered to planners as an empty destination of a type
|
||||
// the node lacks. A node whose entries are all empty keeps them, as disks of
|
||||
// unknown capacity.
|
||||
func IsPhantomDiskTypeEntry(node *master_pb.DataNodeInfo, diskType string) bool {
|
||||
if node == nil || !isEmptyDiskTypeEntry(node.DiskInfos[diskType]) {
|
||||
return false
|
||||
}
|
||||
for otherType, other := range node.DiskInfos {
|
||||
if otherType != diskType && other != nil && !isEmptyDiskTypeEntry(other) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CountTopologyResources counts datacenters, nodes, and disks in topology info
|
||||
func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, nodeCount, diskCount int) {
|
||||
if topologyInfo == nil {
|
||||
@@ -22,7 +52,10 @@ func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, node
|
||||
// DiskInfos is keyed by disk type, so same-type physical disks
|
||||
// collapse into one entry. Count physical disks so the number
|
||||
// matches the per-disk activeDisk map.
|
||||
for _, diskInfo := range node.DiskInfos {
|
||||
for diskType, diskInfo := range node.DiskInfos {
|
||||
if IsPhantomDiskTypeEntry(node, diskType) {
|
||||
continue
|
||||
}
|
||||
diskCount += len(diskInfo.SplitByPhysicalDisk())
|
||||
}
|
||||
}
|
||||
@@ -82,6 +115,9 @@ func (at *ActiveTopology) UpdateTopology(topologyInfo *master_pb.TopologyInfo) e
|
||||
// One activeDisk per physical disk_id: the master keys
|
||||
// DiskInfos by disk type, so same-type disks must be split out.
|
||||
for diskType, diskInfo := range nodeInfo.DiskInfos {
|
||||
if IsPhantomDiskTypeEntry(nodeInfo, diskType) {
|
||||
continue
|
||||
}
|
||||
perDiskInfos := diskInfo.SplitByPhysicalDisk()
|
||||
for _, perDisk := range perDiskInfos {
|
||||
disk := &activeDisk{
|
||||
|
||||
@@ -256,11 +256,13 @@ templ Admin(data dash.AdminData) {
|
||||
if tier.IsRemote {
|
||||
<span class="text-muted">-</span>
|
||||
} else {
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style={fmt.Sprintf("width: %d%%", tier.UsagePercent())}>
|
||||
{fmt.Sprintf("%d%%", tier.UsagePercent())}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress flex-grow-1" style="height: 20px; min-width: 48px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style={fmt.Sprintf("width: %d%%", tier.UsagePercent())}>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ms-2 text-nowrap">{fmt.Sprintf("%d%%", tier.UsagePercent())}</span>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
@@ -445,11 +447,13 @@ templ Admin(data dash.AdminData) {
|
||||
<td>{vs.DataCenter}</td>
|
||||
<td>{vs.Rack}</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style={fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes))}>
|
||||
{fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes)}
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress flex-grow-1" style="height: 20px; min-width: 48px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style={fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes))}>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ms-2 text-nowrap">{fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -342,33 +342,33 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"progress\" style=\"height: 20px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"d-flex align-items-center\"><div class=\"progress flex-grow-1\" style=\"height: 20px; min-width: 48px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", tier.UsagePercent()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 261, Col: 119}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 262, Col: 123}
|
||||
}
|
||||
_, 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, 33, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"></div></div><span class=\"ms-2 text-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%%", tier.UsagePercent()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 262, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 265, Col: 132}
|
||||
}
|
||||
_, 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, 34, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -390,7 +390,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var20 templ.SafeURL
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster/mount-clients"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 286, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 288, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -403,7 +403,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalMountClients))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 294, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 296, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -421,7 +421,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var22 templ.SafeURL
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("//%s/ui/index.html", master.Address)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 325, Col: 121}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 327, Col: 121}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -434,7 +434,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 326, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 328, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -467,7 +467,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.MasterNodes)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 359, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 361, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -480,7 +480,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.VolumeServers)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 367, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 369, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -493,7 +493,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.FilerNodes)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 375, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 377, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -506,7 +506,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.S3Nodes)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 383, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 385, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -519,7 +519,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.MessageBrokers)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 391, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 393, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -532,7 +532,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var29 templ.SafeURL
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/volumes"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 416, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 418, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -545,7 +545,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var30 templ.SafeURL
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 417, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 419, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -563,7 +563,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(vs.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 438, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 440, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -576,7 +576,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var32 templ.SafeURL
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("//%s/ui/index.html", vs.GetDisplayAddress())))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 440, Col: 129}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 442, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -589,7 +589,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(vs.GetDisplayAddress())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 441, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 443, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -602,7 +602,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(vs.DataCenter)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 445, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 447, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -615,39 +615,39 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(vs.Rack)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 446, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 448, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td><td><div class=\"progress\" style=\"height: 20px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td><td><div class=\"d-flex align-items-center\"><div class=\"progress flex-grow-1\" style=\"height: 20px; min-width: 48px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", calculatePercent(vs.Volumes, vs.MaxVolumes)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 450, Col: 135}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 453, Col: 139}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"></div></div><span class=\"ms-2 text-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", vs.Volumes, vs.MaxVolumes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 451, Col: 104}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 456, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div></div></td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span></div></td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -659,7 +659,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", vs.EcShards))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 457, Col: 127}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 461, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -677,7 +677,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d vol", vs.EcVolumes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 459, Col: 119}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 463, Col: 119}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -701,7 +701,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.DiskUsage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 466, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 470, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -714,7 +714,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.DiskCapacity))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 466, Col: 107}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 470, Col: 107}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -732,7 +732,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(vs.RemoteSize))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 469, Col: 137}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 473, Col: 137}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -761,7 +761,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var43 templ.SafeURL
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/filer"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 504, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 508, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -774,7 +774,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var44 templ.SafeURL
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 505, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 509, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -792,7 +792,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var45 templ.SafeURL
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("//%s", filer.Address)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 524, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 528, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -805,7 +805,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Address)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 525, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 529, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -818,7 +818,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(filer.DataCenter)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 529, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 533, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -831,7 +831,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(filer.Rack)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 530, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 534, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -844,7 +844,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(filer.LastUpdated.Format("2006-01-02 15:04:05"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 531, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 535, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -868,7 +868,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var50 templ.SafeURL
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/cluster/s3"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 564, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 568, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -886,7 +886,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(s3Node.Address)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 581, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 585, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -899,7 +899,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(s3Node.DataCenter)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 582, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 586, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -912,7 +912,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(s3Node.LastUpdated.Format("2006-01-02 15:04:05"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 583, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 587, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -936,7 +936,7 @@ func Admin(data dash.AdminData) templ.Component {
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 607, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/admin.templ`, Line: 611, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -154,7 +154,7 @@ func init() {
|
||||
filerS3Options.metricsHttpPort = cmdFiler.Flag.Int("s3.metricsPort", 0, "Prometheus metrics listen port")
|
||||
filerS3Options.metricsHttpIp = cmdFiler.Flag.String("s3.metricsIp", "", "metrics listen ip. If empty, default to same as -s3.ip.bind option.")
|
||||
cmdFiler.Flag.Bool("s3.allowEmptyFolder", true, "deprecated, ignored. Empty folder cleanup is now automatic.")
|
||||
filerS3Options.allowDeleteBucketNotEmpty = cmdFiler.Flag.Bool("s3.allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
filerS3Options.allowDeleteBucketNotEmpty = cmdFiler.Flag.Bool("s3.allowDeleteBucketNotEmpty", false, "allow recursive deleting all entries along with bucket")
|
||||
filerS3Options.autoCreateBucket = cmdFiler.Flag.Bool("s3.autoCreateBucket", true, "create the bucket on upload if it does not exist, for admin identities only")
|
||||
filerS3Options.localSocket = cmdFiler.Flag.String("s3.localSocket", "", "default to /tmp/seaweedfs-s3-<port>.sock")
|
||||
filerS3Options.tlsCACertificate = cmdFiler.Flag.String("s3.cacert.file", "", "path to the TLS CA certificate file")
|
||||
|
||||
@@ -195,7 +195,7 @@ func (option *RemoteSyncOptions) makeEventProcessor(remoteStorage *remote_pb.Rem
|
||||
return updateLocalEntry(option, message.NewParentPath, message.NewEntry, remoteEntry)
|
||||
}
|
||||
if filer_pb.IsDelete(resp) {
|
||||
return processDeleteEvent(client, mountedDir, remoteStorageMountLocation, resp)
|
||||
return processDeleteEvent(filerSource, client, mountedDir, remoteStorageMountLocation, resp)
|
||||
}
|
||||
if message.OldEntry != nil && message.NewEntry != nil {
|
||||
return processUpdateEvent(option, filerSource, *option.storageClass, client, mountedDir, remoteStorageMountLocation, resp)
|
||||
@@ -212,8 +212,11 @@ func (option *RemoteSyncOptions) makeEventProcessor(remoteStorage *remote_pb.Rem
|
||||
// event the inline delete already handled), and GCS reports that case as
|
||||
// ErrRemoteObjectNotFound where S3 and Azure answer an idempotent success.
|
||||
// Returning the error would pin the sync offset on an event that has nothing
|
||||
// left to do.
|
||||
// left to do. A file whose path a remote-only entry has taken since keeps its
|
||||
// object: a rename copied that entry's content to this key, and it exists
|
||||
// nowhere else.
|
||||
func processDeleteEvent(
|
||||
filerSource filer_pb.FilerClient,
|
||||
client remote_storage.RemoteStorageClient,
|
||||
mountedDir string,
|
||||
remoteStorageMountLocation *remote_pb.RemoteStorageLocation,
|
||||
@@ -232,6 +235,36 @@ func processDeleteEvent(
|
||||
}
|
||||
glog.V(2).Infof("delete: %+v", resp)
|
||||
dest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(resp.Directory, message.OldEntry.Name), remoteStorageMountLocation)
|
||||
if !message.OldEntry.IsDirectory {
|
||||
// a deleted entry whose rename copy was still pending read its
|
||||
// content from the source its marker names, which nothing else
|
||||
// releases
|
||||
if err := releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
// The object at the key may have been replaced since the delete: a
|
||||
// rename copies another entry's content onto it, a rewrite uploads
|
||||
// over it, and a copy can land before its entry is visible. Delete
|
||||
// only an object the event's stamp still describes.
|
||||
if live, statErr := client.StatFile(dest); statErr == nil {
|
||||
if oldRemote := message.OldEntry.GetRemoteEntry(); oldRemote == nil ||
|
||||
!describes(oldRemote, live) ||
|
||||
oldRemote.RemoteETag != "" && live.RemoteETag == "" {
|
||||
glog.V(0).Infof("keep %s: the object changed since the delete", remote_storage.FormatLocation(dest))
|
||||
return nil
|
||||
}
|
||||
} else if !errors.Is(statErr, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return statErr
|
||||
}
|
||||
current, err := currentEntry(filerSource, resp.Directory, message.OldEntry.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
glog.V(0).Infof("keep %s: a remote-only entry has taken its path since", remote_storage.FormatLocation(dest))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if message.OldEntry.IsDirectory {
|
||||
glog.V(0).Infof("rmdir %s", remote_storage.FormatLocation(dest))
|
||||
return client.RemoveDirectory(dest)
|
||||
@@ -240,6 +273,41 @@ func processDeleteEvent(
|
||||
return deleteRemoteFile(client, dest)
|
||||
}
|
||||
|
||||
// releasePendingSource deletes the source a pending-rename marker on entry
|
||||
// names, once nothing needs it. The filer never deletes a marked source: a
|
||||
// concurrent rename can copy onto that key before its entry is visible. So
|
||||
// the object goes only while it is still the one entry's stamp describes, and
|
||||
// no remote-only entry holds its path.
|
||||
func releasePendingSource(filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, mountedDir string, remoteStorageMountLocation *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry) error {
|
||||
src := util.FullPath(entry.GetExtended()[filer.ExtRemoteRenameSourceKey])
|
||||
if src == "" || !strings.HasPrefix(string(src), strings.TrimSuffix(mountedDir, "/")+"/") {
|
||||
return nil
|
||||
}
|
||||
srcDest := toRemoteStorageLocation(util.FullPath(mountedDir), src, remoteStorageMountLocation)
|
||||
srcDir, srcName := src.DirAndName()
|
||||
current, err := currentEntry(filerSource, srcDir, srcName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
glog.V(0).Infof("keep %s: a remote-only entry holds the pending rename's source", remote_storage.FormatLocation(srcDest))
|
||||
return nil
|
||||
}
|
||||
live, err := client.StatFile(srcDest)
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stamp := entry.GetRemoteEntry(); !describes(stamp, live) || stamp.RemoteETag != "" && live.RemoteETag == "" {
|
||||
glog.V(0).Infof("keep %s: the pending rename's source changed since", remote_storage.FormatLocation(srcDest))
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("delete %s, the source of a pending rename", remote_storage.FormatLocation(srcDest))
|
||||
return deleteRemoteFile(client, srcDest)
|
||||
}
|
||||
|
||||
// deleteRemoteFile deletes the object and treats an already-absent object as
|
||||
// deleted.
|
||||
func deleteRemoteFile(client remote_storage.RemoteStorageClient, dest *remote_pb.RemoteStorageLocation) error {
|
||||
@@ -268,6 +336,17 @@ func processUpdateEvent(
|
||||
}
|
||||
oldDest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(resp.Directory, message.OldEntry.Name), remoteStorageMountLocation)
|
||||
dest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(message.NewParentPath, message.NewEntry.Name), remoteStorageMountLocation)
|
||||
eventSource := renameSource{path: util.NewFullPath(resp.Directory, message.OldEntry.Name), loc: oldDest, stamp: message.OldEntry.GetRemoteEntry()}
|
||||
// sourceOf is where a remote-only entry's content is: the source its
|
||||
// pending-rename marker names, which may be an earlier rename's old key,
|
||||
// or else the event's old key
|
||||
sourceOf := func(entry *filer_pb.Entry, fallback renameSource) renameSource {
|
||||
if src := string(entry.Extended[filer.ExtRemoteRenameSourceKey]); strings.HasPrefix(src, strings.TrimSuffix(mountedDir, "/")+"/") {
|
||||
// a pending entry carries the stamp of the source it reads
|
||||
return renameSource{path: util.FullPath(src), loc: toRemoteStorageLocation(util.FullPath(mountedDir), util.FullPath(src), remoteStorageMountLocation), stamp: entry.GetRemoteEntry()}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
if proto.Equal(oldDest, dest) && !shouldSendToRemote(message.NewEntry) {
|
||||
glog.V(2).Infof("skipping updating: %+v", resp)
|
||||
return nil
|
||||
@@ -307,7 +386,23 @@ func processUpdateEvent(
|
||||
return err
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
return completeRemoteOnlyRename(filerClient, client, message.NewParentPath, current, oldDest, dest, storageClass)
|
||||
source := sourceOf(current, eventSource)
|
||||
if err := completeRemoteOnlyRename(filerClient, client, message.NewParentPath, current, source, dest, storageClass); err != nil {
|
||||
return err
|
||||
}
|
||||
// a pending entry the filer copied on this rename leaves the
|
||||
// source its marker named
|
||||
if pending := sourceOf(message.OldEntry, renameSource{}); pending.loc != nil && !proto.Equal(pending.loc, source.loc) {
|
||||
return releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if current == nil && len(message.NewEntry.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
// the rename's copy was pending and the entry has moved on: a
|
||||
// later rename carries the source in its own marker and finishes
|
||||
// or releases it, so this one leaves it
|
||||
glog.V(0).Infof("keep %s: the pending rename moved on", remote_storage.FormatLocation(oldDest))
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("delete %s", remote_storage.FormatLocation(oldDest))
|
||||
if err := client.DeleteFile(oldDest); err != nil {
|
||||
@@ -323,14 +418,43 @@ func processUpdateEvent(
|
||||
if errors.Is(writeErr, errSuperseded) {
|
||||
glog.Errorf("skipping %s: %v", remote_storage.FormatLocation(dest), writeErr)
|
||||
if !proto.Equal(oldDest, dest) {
|
||||
return uploadCurrentEntry(filerClient, filerSource, client, message.NewParentPath, message.NewEntry.Name, oldDest, dest, storageClass)
|
||||
if err := uploadCurrentEntry(filerClient, filerSource, client, message.NewParentPath, message.NewEntry.Name, eventSource, dest, storageClass, sourceOf); err != nil {
|
||||
return err
|
||||
}
|
||||
return releaseRenamedSource(filerSource, client, mountedDir, remoteStorageMountLocation, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return updateLocalEntry(filerClient, message.NewParentPath, message.NewEntry, remoteEntry)
|
||||
if err := updateLocalEntry(filerClient, message.NewParentPath, message.NewEntry, remoteEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
if !proto.Equal(oldDest, dest) {
|
||||
return releaseRenamedSource(filerSource, client, mountedDir, remoteStorageMountLocation, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseRenamedSource releases a pending source the renamed entry's old
|
||||
// version read, once the destination holds the content on the remote under
|
||||
// its own key: only after the upload and its stamp, which clears the marker,
|
||||
// and only if the entry does not read through a marker any more. Released
|
||||
// earlier, an upload that fails followed by remote.uncache would leave the
|
||||
// entry reading a deleted source.
|
||||
func releaseRenamedSource(filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, mountedDir string, remoteStorageMountLocation *remote_pb.RemoteStorageLocation, message *filer_pb.EventNotification) error {
|
||||
if len(message.OldEntry.GetExtended()[filer.ExtRemoteRenameSourceKey]) == 0 {
|
||||
return nil
|
||||
}
|
||||
current, err := currentEntry(filerSource, message.NewParentPath, message.NewEntry.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != nil && len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
return nil
|
||||
}
|
||||
return releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry)
|
||||
}
|
||||
|
||||
// currentEntry returns what the filer holds at dir/name now, nil when the
|
||||
@@ -359,11 +483,24 @@ func isRemoteOnly(entry *filer_pb.Entry) bool {
|
||||
// copied over the destination, the entry is stamped with the copy, and the
|
||||
// old key goes. With neither object present the content is lost: the event
|
||||
// fails and holds the offset for recovery.
|
||||
func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir string, current *filer_pb.Entry, oldDest, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
|
||||
func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir string, current *filer_pb.Entry, source renameSource, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
|
||||
oldDest := source.loc
|
||||
// the source's recorded stamp; a retry can find the entry already
|
||||
// stamped with the copy, whose ETag a multipart source does not share
|
||||
sourceStamp := source.stamp
|
||||
if sourceStamp == nil {
|
||||
sourceStamp = proto.Clone(current.RemoteEntry).(*filer_pb.RemoteEntry)
|
||||
}
|
||||
if existing, err := client.StatFile(dest); err == nil {
|
||||
if describes(current.RemoteEntry, existing) {
|
||||
glog.V(0).Infof("%s already holds the renamed content", remote_storage.FormatLocation(dest))
|
||||
return deleteRemoteFile(client, oldDest)
|
||||
if len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
// reads still go to the source until the stamp clears the marker
|
||||
if err := updateLocalEntry(filerClient, dir, current, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteRenameSource(filerClient, client, dir, current.Name, source, sourceStamp)
|
||||
}
|
||||
glog.V(0).Infof("%s holds an object the entry does not describe (size %d, want %d); replacing it from %s", remote_storage.FormatLocation(dest), existing.RemoteSize, current.RemoteEntry.RemoteSize, remote_storage.FormatLocation(oldDest))
|
||||
} else if !errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
@@ -389,7 +526,42 @@ func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_st
|
||||
if err := updateLocalEntry(filerClient, dir, current, remoteEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
return deleteRemoteFile(client, oldDest)
|
||||
return deleteRenameSource(filerClient, client, dir, current.Name, source, sourceStamp)
|
||||
}
|
||||
|
||||
// renameSource is where a renamed remote-only entry's content was read
|
||||
// from: the path, the object it maps to, and the stamp describing it.
|
||||
type renameSource struct {
|
||||
path util.FullPath
|
||||
loc *remote_pb.RemoteStorageLocation
|
||||
stamp *filer_pb.RemoteEntry
|
||||
}
|
||||
|
||||
// deleteRenameSource deletes a rename's old key once the renamed entry no
|
||||
// longer reads it: an entry still carrying the pending-source marker (its
|
||||
// stamp lost a race) keeps the source, and the event is retried. The key is
|
||||
// left alone when the object there is no longer the one stamp describes, as
|
||||
// when another rename copied onto it, visible yet or not.
|
||||
func deleteRenameSource(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, source renameSource, stamp *filer_pb.RemoteEntry) error {
|
||||
current, err := currentEntry(filerClient, dir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != nil && len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
|
||||
return fmt.Errorf("%s still reads %s", util.NewFullPath(dir, name), remote_storage.FormatLocation(source.loc))
|
||||
}
|
||||
live, err := client.StatFile(source.loc)
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !describes(stamp, live) || stamp.GetRemoteETag() != "" && live.RemoteETag == "" {
|
||||
glog.V(0).Infof("keep %s: it is no longer the renamed content", remote_storage.FormatLocation(source.loc))
|
||||
return nil
|
||||
}
|
||||
return deleteRemoteFile(client, source.loc)
|
||||
}
|
||||
|
||||
// describes reports whether the object a stat returned is the one the entry's
|
||||
@@ -417,15 +589,15 @@ func openRemoteObject(client remote_storage.RemoteStorageClient, loc *remote_pb.
|
||||
}
|
||||
|
||||
// uploadCurrentEntry uploads what the filer holds at dir/name now, when the
|
||||
// event that superseded this rename will not. A superseded rename has
|
||||
// already deleted the old key; the rewrite behind it in the log uploads the
|
||||
// destination itself whenever shouldSendToRemote sees it as unsynced, so
|
||||
// only a current entry that reads synced is uploaded here and the content
|
||||
// goes up once. A remote-only entry is finished the way
|
||||
// completeRemoteOnlyRename finishes a rename: its stamp can already describe
|
||||
// the destination (a sync plus remote.uncache in the meantime), and only
|
||||
// then is it complete.
|
||||
func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, oldDest, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
|
||||
// event that superseded this rename will not. A rename whose snapshot is
|
||||
// superseded has already deleted the old key; the rewrite behind it in the
|
||||
// log uploads the destination itself unless shouldSendToRemote skips it on
|
||||
// the inherited RemoteEntry, whose RemoteMtime can equal the rewrite's mtime
|
||||
// within the same second. Only that case uploads here, so the content goes
|
||||
// up once. A remote-only entry is finished the way completeRemoteOnlyRename
|
||||
// finishes a rename: its stamp can already describe the destination (a sync
|
||||
// plus remote.uncache in the meantime), and only then is it complete.
|
||||
func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, oldSource renameSource, dest *remote_pb.RemoteStorageLocation, storageClass string, sourceOf func(*filer_pb.Entry, renameSource) renameSource) error {
|
||||
current, _, _, err := filer_pb.GetEntry(context.Background(), filerSource, util.NewFullPath(dir, name))
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil
|
||||
@@ -437,7 +609,7 @@ func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.F
|
||||
return nil
|
||||
}
|
||||
if isRemoteOnly(current) {
|
||||
return completeRemoteOnlyRename(filerClient, client, dir, current, oldDest, dest, storageClass)
|
||||
return completeRemoteOnlyRename(filerClient, client, dir, current, sourceOf(current, oldSource), dest, storageClass)
|
||||
}
|
||||
if shouldSendToRemote(current) {
|
||||
glog.V(0).Infof("leaving %s to the rewrite that superseded the rename", remote_storage.FormatLocation(dest))
|
||||
@@ -634,6 +806,7 @@ func updateLocalEntry(filerClient filer_pb.FilerClient, dir string, entry *filer
|
||||
remoteEntry.LastLocalSyncTsNs = time.Now().UnixNano()
|
||||
expected := proto.Clone(entry).(*filer_pb.Entry)
|
||||
entry.RemoteEntry = remoteEntry
|
||||
delete(entry.Extended, filer.ExtRemoteRenameSourceKey)
|
||||
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -686,12 +687,20 @@ type recordingRemote struct {
|
||||
// chunk ids of each written entry: which version of the file went up
|
||||
writtenChunks [][]string
|
||||
deleteErr error
|
||||
writeErr error
|
||||
// objects present on the remote, by path: StatFile and ReadFile answer
|
||||
// from it, everything else is ErrRemoteObjectNotFound.
|
||||
objects map[string][]byte
|
||||
// statETag is the ETag StatFile reports for a present object, unless
|
||||
// statETags names one for its path.
|
||||
statETag string
|
||||
statETags map[string]string
|
||||
}
|
||||
|
||||
func (r *recordingRemote) WriteFile(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry, reader io.Reader) (*filer_pb.RemoteEntry, error) {
|
||||
if r.writeErr != nil {
|
||||
return nil, r.writeErr
|
||||
}
|
||||
r.writes = append(r.writes, loc)
|
||||
var ids []string
|
||||
for _, c := range entry.GetChunks() {
|
||||
@@ -714,7 +723,11 @@ func (r *recordingRemote) StatFile(loc *remote_pb.RemoteStorageLocation) (*filer
|
||||
if !ok {
|
||||
return nil, remote_storage.ErrRemoteObjectNotFound
|
||||
}
|
||||
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body))}, nil
|
||||
etag := r.statETag
|
||||
if e, ok := r.statETags[loc.Path]; ok {
|
||||
etag = e
|
||||
}
|
||||
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body)), RemoteETag: etag}, nil
|
||||
}
|
||||
|
||||
func (r *recordingRemote) ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) ([]byte, error) {
|
||||
@@ -1008,7 +1021,7 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
||||
|
||||
t.Run("absent object", func(t *testing.T) {
|
||||
remote := &recordingRemote{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
||||
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err != nil {
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v, want nil: deleting an absent object is complete", err)
|
||||
}
|
||||
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
|
||||
@@ -1020,7 +1033,7 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
||||
marked := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
marked.EventNotification.OldEntry.Extended = map[string][]byte{filer.ExtKeepRemoteObjectKey: []byte("true")}
|
||||
remote := &recordingRemote{}
|
||||
if err := processDeleteEvent(remote, mountedDir, mountLoc, marked); err != nil {
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, marked); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
@@ -1028,9 +1041,108 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a remote-only entry that took the path keeps the object", func(t *testing.T) {
|
||||
// deleted, then a remote-only file renamed onto the path: the rename
|
||||
// copied its content to this key before the sync got to the delete
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: &filer_pb.Entry{
|
||||
Name: "state.db.tmp",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 10},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 10},
|
||||
}}
|
||||
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a replaced object is kept", func(t *testing.T) {
|
||||
// the delete recorded the old object's etag; a rename copied another
|
||||
// entry's content onto the key since, and it must not go with it
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 10}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
|
||||
filerClient := &stubFilerClient{entry: &filer_pb.Entry{Name: "state.db.tmp", Content: []byte("copied")}}
|
||||
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a changed object with no entry at the path yet is kept", func(t *testing.T) {
|
||||
// a directory rename copies a child onto the key before its new entry
|
||||
// becomes visible; the copy may be the content's only remaining home
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 10}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unchanged object still goes", func(t *testing.T) {
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 3}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("old")}, statETag: `"orig"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 1 {
|
||||
t.Errorf("deletes = %+v, want one", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a copy that landed before its entry is kept", func(t *testing.T) {
|
||||
// the deleted entry was never replicated, so its event records no
|
||||
// stamp to match the fresh rename copy waiting for its filer entry
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unverifiable object is kept", func(t *testing.T) {
|
||||
// the delete recorded an etag the live stat does not report; the
|
||||
// object cannot be confirmed as the one the delete meant
|
||||
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 3}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("old")}}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an entry with local data at the path does not stop the delete", func(t *testing.T) {
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: &filer_pb.Entry{
|
||||
Name: "state.db.tmp",
|
||||
Content: []byte("local"),
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 10},
|
||||
}}
|
||||
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, resp); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(remote.deletes) != 1 {
|
||||
t.Errorf("deletes = %+v, want one", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other failure still fails", func(t *testing.T) {
|
||||
remote := &recordingRemote{deleteErr: errors.New("AccessDenied: Access Denied")}
|
||||
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err == nil {
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err == nil {
|
||||
t.Fatal("err = nil, want the delete failure")
|
||||
}
|
||||
})
|
||||
@@ -1281,7 +1393,7 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
||||
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 1024})
|
||||
remote := &recordingRemote{}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, "")
|
||||
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", renameSource{loc: wantDelete}, wantWrite, "", eventSource)
|
||||
if err == nil || !strings.Contains(err.Error(), "neither") {
|
||||
t.Errorf("err = %v, want the lost-content failure", err)
|
||||
}
|
||||
@@ -1292,9 +1404,9 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
||||
|
||||
t.Run("uncached with the destination already synced: complete without a write", func(t *testing.T) {
|
||||
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/b.txt": []byte("payload")}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload"), "/b/dir/b.txt": []byte("payload")}}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, ""); err != nil {
|
||||
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", renameSource{loc: wantDelete}, wantWrite, "", eventSource); err != nil {
|
||||
t.Fatalf("err = %v, want nil: the destination object matches the entry's stamp", err)
|
||||
}
|
||||
if len(remote.writes) != 0 {
|
||||
@@ -1306,6 +1418,228 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameClearsThePendingSourceFirst(t *testing.T) {
|
||||
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/src/a.jpg"}
|
||||
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/dst/a.jpg"}
|
||||
pending := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "a.jpg",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 4},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"copy"`, RemoteSize: 4},
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/src/a.jpg")},
|
||||
}
|
||||
}
|
||||
objects := func() map[string][]byte {
|
||||
return map[string][]byte{"/b/src/a.jpg": []byte("data"), "/b/dst/a.jpg": []byte("data")}
|
||||
}
|
||||
|
||||
t.Run("stamped, then the source goes", func(t *testing.T) {
|
||||
// the destination already holds the copy; the entry still reads the
|
||||
// source until its stamp clears the marker
|
||||
current := pending()
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
remote := &recordingRemote{objects: objects(), statETag: `"copy"`}
|
||||
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b/dst", current, renameSource{path: "/buckets/b/src/a.jpg", loc: oldDest}, dest, ""); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if filerClient.updates != 1 {
|
||||
t.Errorf("updates = %d, want the stamp that clears the marker", filerClient.updates)
|
||||
}
|
||||
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/src/a.jpg" {
|
||||
t.Errorf("deletes = %+v, want the source", remote.deletes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a marker still in place keeps the source", func(t *testing.T) {
|
||||
// the stamp did not land: the filer still has the marker
|
||||
filerClient := &stubFilerClient{entry: pending()}
|
||||
remote := &recordingRemote{objects: objects(), statETag: `"copy"`}
|
||||
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b/dst", pending(), renameSource{path: "/buckets/b/src/a.jpg", loc: oldDest}, dest, ""); err == nil {
|
||||
t.Fatal("err = nil, want the event retried")
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want none while the entry reads the source", remote.deletes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// eventSource resolves a rename's source to the event's old key.
|
||||
func eventSource(_ *filer_pb.Entry, source renameSource) renameSource {
|
||||
return source
|
||||
}
|
||||
|
||||
func TestRenameOfAPendingEntryFollowsItsSource(t *testing.T) {
|
||||
// A was renamed over B and B over C, both copies failing: C reads A's
|
||||
// object through the marker, and B's key holds the old target's bytes
|
||||
const mountedDir = "/buckets"
|
||||
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
||||
remoteOnly := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}
|
||||
pendingC := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "c.jpg",
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
|
||||
RemoteEntry: remoteOnly,
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")},
|
||||
}
|
||||
}
|
||||
event := func(oldName, newName string) *filer_pb.SubscribeMetadataResponse {
|
||||
newEntry := pendingC()
|
||||
newEntry.Name = newName
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/buckets/b",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: oldName, RemoteEntry: remoteOnly},
|
||||
NewParentPath: "/buckets/b",
|
||||
NewEntry: newEntry,
|
||||
},
|
||||
}
|
||||
}
|
||||
objects := func() map[string][]byte {
|
||||
return map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("old-c")}
|
||||
}
|
||||
|
||||
t.Run("the first rename leaves the source to the entry that moved on", func(t *testing.T) {
|
||||
remote := &recordingRemote{objects: objects()}
|
||||
filerClient := &stubFilerClient{} // nothing at b.jpg any more
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event("a.jpg", "b.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.deletes) != 0 || len(remote.writes) != 0 {
|
||||
t.Errorf("deletes = %+v, writes = %+v, want neither: c.jpg still reads a.jpg", remote.deletes, remote.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the second rename copies from the marker's source", func(t *testing.T) {
|
||||
current := pendingC()
|
||||
remote := &recordingRemote{objects: objects(), statETag: `"a"`}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event("b.jpg", "c.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.written) != 1 || string(remote.written[0]) != "data-a" {
|
||||
t.Errorf("written = %q, want a.jpg's content", remote.written)
|
||||
}
|
||||
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/a.jpg" {
|
||||
t.Errorf("deletes = %+v, want the source once the stamp cleared the marker", remote.deletes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPendingRenameSourceIsReleasedOnlyWhileUnchanged(t *testing.T) {
|
||||
const mountedDir = "/buckets"
|
||||
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
|
||||
// b.jpg's copy failed after it replaced a target: it read a.jpg's object
|
||||
pendingB := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{
|
||||
Name: "b.jpg",
|
||||
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6},
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")},
|
||||
}
|
||||
}
|
||||
deleted := func() *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{OldEntry: pendingB()}}
|
||||
}
|
||||
deletedPaths := func(r *recordingRemote) (paths []string) {
|
||||
for _, d := range r.deletes {
|
||||
paths = append(paths, d.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("deleting the pending entry releases its source", func(t *testing.T) {
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the source", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a source replaced since is kept", func(t *testing.T) {
|
||||
// another rename copied onto a.jpg before its entry became visible
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-d")}, statETag: `"d"`}
|
||||
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the replaced source kept", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a destination cached since releases the source too", func(t *testing.T) {
|
||||
// c.jpg was cached before the sync reached the rename: it is no
|
||||
// longer remote-only, and a.jpg is still left over
|
||||
cached := &filer_pb.Entry{Name: "c.jpg", Content: []byte("data-a"), Attributes: &filer_pb.FuseAttributes{FileSize: 6}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: cached,
|
||||
}}
|
||||
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": cached}}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the pending source released", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a cached entry still reading the source keeps it until its upload lands", func(t *testing.T) {
|
||||
// c.jpg was cached through its marker; until the upload is stamped
|
||||
// and the marker cleared, a.jpg is what remote.uncache would fall
|
||||
// back to
|
||||
marked := func() *filer_pb.Entry {
|
||||
return &filer_pb.Entry{Name: "c.jpg", Content: []byte("data-a"), Attributes: &filer_pb.FuseAttributes{FileSize: 6},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6},
|
||||
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")}}
|
||||
}
|
||||
for name, writeErr := range map[string]error{"upload fails": errors.New("upload failed"), "stamp does not land": nil} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b")}, statETag: `"a"`, writeErr: writeErr}
|
||||
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: marked(),
|
||||
}}
|
||||
// the filer keeps the marker: the stub does not store updates
|
||||
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": marked()}}
|
||||
_ = processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event)
|
||||
if got := deletedPaths(remote); slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the source kept while the entry reads it", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a later rename the filer copied releases the source", func(t *testing.T) {
|
||||
// b.jpg was renamed to c.jpg and the filer copied a.jpg there at once
|
||||
copied := &filer_pb.Entry{Name: "c.jpg", Attributes: &filer_pb.FuseAttributes{FileSize: 6}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: copied,
|
||||
}}
|
||||
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": copied}}
|
||||
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
|
||||
t.Errorf("deletes = %v, want the pending source released", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pathFilerClient serves entries by path; a path it lacks is not found.
|
||||
type pathFilerClient struct {
|
||||
stubFilerClient
|
||||
entries map[string]*filer_pb.Entry
|
||||
}
|
||||
|
||||
func (c *pathFilerClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: c.entries[req.Directory+"/"+req.Name]}, nil
|
||||
}
|
||||
|
||||
func (c *pathFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c)
|
||||
}
|
||||
|
||||
func TestShouldSendToRemote(t *testing.T) {
|
||||
const mtime = 1786096669
|
||||
|
||||
@@ -1383,3 +1717,37 @@ func TestMetadataOnlyUpdateRestampsSyncTs(t *testing.T) {
|
||||
t.Error("the restamped entry should read synced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameKeepsAReusedOldKey(t *testing.T) {
|
||||
// A was renamed to B, then D to A before the sync got to the first
|
||||
// rename: the object at A is now D's, and the only copy of it
|
||||
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/a.jpg"}
|
||||
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/b.jpg"}
|
||||
current := &filer_pb.Entry{Name: "b.jpg", RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"a"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-d"), "/b/b.jpg": []byte("data-a")}, statETag: `"a"`}
|
||||
remote.statETags = map[string]string{"/b/a.jpg": `"d"`}
|
||||
filerClient := &stubFilerClient{entry: current}
|
||||
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b", current, renameSource{path: "/buckets/b/a.jpg", loc: oldDest}, dest, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.deletes) != 0 {
|
||||
t.Errorf("deletes = %+v, want the reused key kept", remote.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRemoteOnlyRenameRetryUsesTheSourceStamp(t *testing.T) {
|
||||
// an earlier run copied a multipart source and stamped the entry with
|
||||
// the copy's ETag, then failed to delete the source
|
||||
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/a.jpg"}
|
||||
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/b.jpg"}
|
||||
current := &filer_pb.Entry{Name: "b.jpg", RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"copy"`, RemoteSize: 6}}
|
||||
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("data-a")},
|
||||
statETags: map[string]string{"/b/a.jpg": `"a-2"`, "/b/b.jpg": `"copy"`}}
|
||||
source := renameSource{path: "/buckets/b/a.jpg", loc: oldDest, stamp: &filer_pb.RemoteEntry{RemoteETag: `"a-2"`, RemoteSize: 6}}
|
||||
if err := completeRemoteOnlyRename(&stubFilerClient{entry: current}, remote, "/buckets/b", current, source, dest, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/a.jpg" {
|
||||
t.Errorf("deletes = %+v, want the unchanged source deleted on the retry", remote.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
+180
-59
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -64,8 +65,9 @@ var (
|
||||
miniS3Options S3Options
|
||||
miniWebDavOptions WebDavOption
|
||||
miniAdminOptions AdminOptions
|
||||
// Track which port flags were explicitly passed on CLI before config file is applied
|
||||
explicitPortFlags map[string]bool
|
||||
// Flags explicitly provided on the command line or via WEED_* env vars,
|
||||
// captured before the saved options file is applied
|
||||
explicitFlags map[string]bool
|
||||
miniEnableWebDAV *bool
|
||||
miniEnableS3 *bool
|
||||
miniEnableAdminUI *bool
|
||||
@@ -376,6 +378,19 @@ The S3 endpoint is available at http://localhost:8333. For client
|
||||
configuration and IAM setup, see the project documentation or use the
|
||||
Admin UI (http://localhost:23646) to manage users and policies.
|
||||
|
||||
Notes:
|
||||
- Values mini computes itself (currently master.volumeSizeLimitMB) are
|
||||
pinned in <dir>/mini.options and applied again on the next start to
|
||||
keep them stable; applied values are printed at startup. Command-line
|
||||
flags are never saved and always win over saved values. Edit or
|
||||
delete mini.options to reset a computed value.
|
||||
- The official Docker image ships /etc/seaweedfs/filer.toml, which keeps
|
||||
the filer metadata in /data/filerldb2 regardless of -dir; mini warns
|
||||
at startup when a configured store dir is outside -dir. If you set
|
||||
-dir to another path, keep /data on a persistent volume as well.
|
||||
Otherwise the metadata is lost when the container is recreated, and
|
||||
buckets list no objects although the volume files are still in -dir.
|
||||
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -392,7 +407,7 @@ var (
|
||||
miniMetricsHttpIp = cmdMini.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||
miniS3Config = cmdMini.Flag.String("s3.config", "", "path to the S3 config file")
|
||||
miniIamConfig = cmdMini.Flag.String("s3.iam.config", "", "path to the advanced IAM config file for S3")
|
||||
miniS3AllowDeleteBucketNotEmpty = cmdMini.Flag.Bool("s3.allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
miniS3AllowDeleteBucketNotEmpty = cmdMini.Flag.Bool("s3.allowDeleteBucketNotEmpty", false, "allow recursive deleting all entries along with bucket")
|
||||
miniS3AutoCreateBucket = cmdMini.Flag.Bool("s3.autoCreateBucket", true, "create the bucket on upload if it does not exist, for admin identities only")
|
||||
miniBucket = cmdMini.Flag.String("bucket", "", "comma-separated S3 bucket names to create on startup if they do not already exist; leave empty to skip. Falls back to S3_BUCKET env var.")
|
||||
miniTableBucket = cmdMini.Flag.String("tableBucket", "", "comma-separated S3 Tables buckets to create on startup if they do not already exist, each name[:FORMAT] with FORMAT one of ICEBERG (default) or LANCE, e.g. warehouse,vectors:LANCE; leave empty to skip. Falls back to S3_TABLE_BUCKET env var.")
|
||||
@@ -486,6 +501,7 @@ func initMiniVolumeFlags() {
|
||||
miniOptions.v.readMode = cmdMini.Flag.String("volume.readMode", "proxy", "[local|proxy|redirect] how to deal with non-local volume: 'not found|read in remote node|redirect volume location'.")
|
||||
miniOptions.v.compactionMBPerSecond = cmdMini.Flag.Int("volume.compactionMBps", 0, "limit compaction speed in mega bytes per second")
|
||||
miniOptions.v.maintenanceMBPerSecond = cmdMini.Flag.Int("volume.maintenanceMBps", 0, "limit maintenance IO rate in MB/s")
|
||||
miniOptions.v.maxParallelVacuumCommits = cmdMini.Flag.Int("volume.compactionMaxParallelCommits", 0, "limit how many vacuum commits run at once on this server; queued commits hold no volume lock. 0 means unlimited")
|
||||
miniOptions.v.fileSizeLimitMB = cmdMini.Flag.Int("volume.fileSizeLimitMB", 256, "limit file size to avoid out of memory")
|
||||
miniOptions.v.ldbTimeout = cmdMini.Flag.Int64("volume.index.leveldbTimeout", 0, "alive time for leveldb")
|
||||
miniOptions.v.concurrentUploadLimitMB = cmdMini.Flag.Int("volume.concurrentUploadLimitMB", 0, "limit total concurrent upload size")
|
||||
@@ -823,8 +839,8 @@ func findAvailablePortOnIP(ip string, startPort int, maxAttempts int, reservedPo
|
||||
// If the port is not available, it finds the next available port and updates the pointer
|
||||
// The reservedPorts map contains ports that should not be allocated (for gRPC collision avoidance)
|
||||
func ensurePortAvailableOnIP(portPtr *int, serviceName string, ip string, reservedPorts map[int]bool, flagName string) error {
|
||||
// Check if this port was explicitly specified by the user (from CLI, before config file was applied)
|
||||
isExplicitPort := explicitPortFlags[flagName]
|
||||
// Check if this port was explicitly specified by the user (from CLI or env, before the config file was applied)
|
||||
isExplicitPort := explicitFlags[flagName]
|
||||
|
||||
if *portPtr == 0 {
|
||||
return nil
|
||||
@@ -1072,10 +1088,16 @@ func initializeGrpcPortsOnIP(bindIp string) {
|
||||
}
|
||||
}
|
||||
|
||||
// miniOptionsFilePath returns the path of the mini.options file under the
|
||||
// first -dir folder.
|
||||
func miniOptionsFilePath(dataFolder string) string {
|
||||
return filepath.Join(util.ResolvePath(util.StringSplit(dataFolder, ",")[0]), "mini.options")
|
||||
}
|
||||
|
||||
// loadMiniConfigurationFile reads the mini.options file and returns parsed options
|
||||
// File format: one option per line, without leading dash (e.g., "ip=127.0.0.1")
|
||||
func loadMiniConfigurationFile(dataFolder string) (map[string]string, error) {
|
||||
configFile := filepath.Join(util.ResolvePath(util.StringSplit(dataFolder, ",")[0]), "mini.options")
|
||||
configFile := miniOptionsFilePath(dataFolder)
|
||||
|
||||
options := make(map[string]string)
|
||||
|
||||
@@ -1123,31 +1145,112 @@ func loadMiniConfigurationFile(dataFolder string) (map[string]string, error) {
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// applyConfigFileOptions sets command-line flags from loaded configuration file
|
||||
func applyConfigFileOptions(options map[string]string) {
|
||||
for key, value := range options {
|
||||
// Skip port flags that were explicitly passed on CLI
|
||||
if explicitPortFlags[key] {
|
||||
glog.V(2).Infof("Skipping config file option %s=%s (explicitly specified on command line)", key, value)
|
||||
// applyConfigFileOptions sets command-line flags from loaded configuration
|
||||
// file. Options for flags the user provided explicitly (command line or
|
||||
// WEED_* env var) are skipped, so passing a flag always wins over the saved
|
||||
// value — including passing it at its default value. Returns a map of the
|
||||
// applied keys to their display values (secrets redacted).
|
||||
func applyConfigFileOptions(options map[string]string) map[string]string {
|
||||
keys := make([]string, 0, len(options))
|
||||
for key := range options {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
applied := make(map[string]string)
|
||||
for _, key := range keys {
|
||||
value := options[key]
|
||||
// Redact secrets (admin.password, admin.readOnlyPassword, ...) in
|
||||
// logs and the startup message; only flag.Value.Set sees the value.
|
||||
display := value
|
||||
if isSensitiveOption(key) {
|
||||
display = "*****"
|
||||
}
|
||||
if explicitFlags[key] {
|
||||
glog.V(2).Infof("Skipping config file option %s=%s (explicitly specified)", key, display)
|
||||
continue
|
||||
}
|
||||
// Set the flag value if it hasn't been explicitly set on command line
|
||||
flag := cmdMini.Flag.Lookup(key)
|
||||
if flag != nil {
|
||||
// Only set if not already set (by command line)
|
||||
if flag.Value.String() == flag.DefValue {
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
glog.Warningf("Failed to apply config file option: %s=%s: %v", key, value, err)
|
||||
} else {
|
||||
glog.V(2).Infof("Applied config file option: %s=%s", key, value)
|
||||
if flag == nil {
|
||||
continue
|
||||
}
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
glog.Warningf("Failed to apply config file option: %s=%s: %v", key, display, err)
|
||||
continue
|
||||
}
|
||||
glog.V(2).Infof("Applied config file option: %s=%s", key, display)
|
||||
applied[key] = display
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// isSensitiveOption reports whether a saved option holds a secret that must
|
||||
// not appear in logs or the startup message.
|
||||
func isSensitiveOption(key string) bool {
|
||||
k := strings.ToLower(key)
|
||||
return strings.Contains(k, "password") || strings.Contains(k, "secret") || strings.Contains(k, "token")
|
||||
}
|
||||
|
||||
// warnIfFilerStoreOutsideDir warns when a filer.toml configures a local
|
||||
// filer store outside the -dir folders and the master meta folder. When a
|
||||
// filer.toml is found the filer skips defaultLevelDbDirectory, so metadata
|
||||
// can silently land on a path the volume data doesn't use — in a container
|
||||
// that path is lost on recreate unless it is persisted separately.
|
||||
func warnIfFilerStoreOutsideDir(dataFolders string, metaFolder string) {
|
||||
if !util.LoadConfiguration("filer", false) {
|
||||
return
|
||||
}
|
||||
v := util.GetViper()
|
||||
folders := append(util.StringSplit(dataFolders, ","), metaFolder)
|
||||
inside := func(storeDir string) bool {
|
||||
// The filer stores open the configured dir verbatim, so absolutize it
|
||||
// the same way (relative to the working directory) rather than
|
||||
// expanding "~", which the store never expands.
|
||||
resolved, err := filepath.Abs(storeDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, folder := range folders {
|
||||
base, err := filepath.Abs(util.ResolvePath(folder))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Resolve symlinks only when both sides exist, so a not-yet-
|
||||
// created store dir under a symlinked -dir still compares
|
||||
// lexically against the same spelling.
|
||||
r, b := resolved, base
|
||||
if er, err := filepath.EvalSymlinks(resolved); err == nil {
|
||||
if eb, err := filepath.EvalSymlinks(base); err == nil {
|
||||
r, b = er, eb
|
||||
}
|
||||
}
|
||||
if rel, err := filepath.Rel(b, r); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Local filer stores keep metadata under a "dir" key; check both the
|
||||
// default store (leveldb2.enabled) and path-specific stores
|
||||
// (leveldb2.<id>.enabled, keyed under <section>.location).
|
||||
localStores := map[string]bool{"leveldb": true, "leveldb2": true, "leveldb3": true}
|
||||
for _, key := range v.AllKeys() {
|
||||
if !strings.HasSuffix(key, ".enabled") || !v.GetBool(key) {
|
||||
continue
|
||||
}
|
||||
section := strings.TrimSuffix(key, ".enabled")
|
||||
if !localStores[strings.Split(section, ".")[0]] {
|
||||
continue
|
||||
}
|
||||
if storeDir := v.GetString(section + ".dir"); storeDir != "" && !inside(storeDir) {
|
||||
glog.Warningf("filer store %s uses dir %s from filer.toml, outside -dir %s; persist that path or filer metadata will be lost when the container is recreated", section, storeDir, dataFolders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveMiniConfiguration saves the current mini configuration to a file
|
||||
// The file format uses option=value format without leading dashes
|
||||
// saveMiniConfiguration writes values mini computed itself to mini.options.
|
||||
// Command-line flags are never saved, so a dropped flag cannot resurrect on
|
||||
// the next start; the file exists to pin environment-derived values.
|
||||
func saveMiniConfiguration(dataFolder string) error {
|
||||
configDir := util.ResolvePath(util.StringSplit(dataFolder, ",")[0])
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
@@ -1159,29 +1262,15 @@ func saveMiniConfiguration(dataFolder string) error {
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("#!/bin/bash\n")
|
||||
sb.WriteString("# Mini server configuration\n")
|
||||
sb.WriteString("# Mini server auto-computed options\n")
|
||||
sb.WriteString("# Format: option=value (no leading dash)\n")
|
||||
sb.WriteString("# This file is loaded on startup if it exists\n\n")
|
||||
sb.WriteString("# This file is loaded on startup if it exists\n")
|
||||
sb.WriteString("# Command-line flags are not saved; only values mini computed itself.\n\n")
|
||||
|
||||
// Collect all flags that were explicitly passed (except "dir")
|
||||
cmdMini.Flag.Visit(func(f *flag.Flag) {
|
||||
// Skip the "dir" option - it's environment-specific
|
||||
if f.Name == "dir" {
|
||||
return
|
||||
}
|
||||
value := f.Value.String()
|
||||
// Quote the value if it contains spaces
|
||||
if strings.Contains(value, " ") {
|
||||
sb.WriteString(fmt.Sprintf("%s=\"%s\"\n", f.Name, value))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%s=%s\n", f.Name, value))
|
||||
}
|
||||
})
|
||||
|
||||
// Add auto-calculated volume size if it was computed
|
||||
// Pin the auto-calculated volume size so it stays stable across restarts
|
||||
// even as free disk space changes. Delete this line to force
|
||||
// recalculation on the next startup.
|
||||
if !isFlagPassed("master.volumeSizeLimitMB") && miniMasterOptions.volumeSizeLimitMB != nil {
|
||||
sb.WriteString(fmt.Sprintf("\n# Auto-calculated volume size based on total disk capacity\n"))
|
||||
sb.WriteString(fmt.Sprintf("# Delete this line to force recalculation on next startup\n"))
|
||||
sb.WriteString(fmt.Sprintf("master.volumeSizeLimitMB=%d\n", *miniMasterOptions.volumeSizeLimitMB))
|
||||
}
|
||||
|
||||
@@ -1203,21 +1292,32 @@ func runMini(cmd *Command, args []string) bool {
|
||||
// can opt back into verbose output.
|
||||
quietMiniLogs()
|
||||
|
||||
// Capture which port flags were explicitly passed on CLI BEFORE config file is applied
|
||||
// This is necessary to distinguish user-specified ports from defaults or config file options
|
||||
explicitPortFlags = make(map[string]bool)
|
||||
portFlagNames := []string{"master.port", "filer.port", "volume.port", "s3.port", "s3.port.iceberg", "webdav.port", "admin.port", "s3.iam.readOnly"}
|
||||
for _, flagName := range portFlagNames {
|
||||
explicitPortFlags[flagName] = isFlagPassed(flagName)
|
||||
}
|
||||
// Capture which flags were explicitly provided on the command line or via
|
||||
// WEED_* env vars BEFORE the saved options file is applied. Saved options
|
||||
// only fill in flags the user did not provide this run, so passing a flag
|
||||
// at its default value also resets a saved value.
|
||||
explicitFlags = make(map[string]bool)
|
||||
cmdMini.Flag.Visit(func(f *flag.Flag) {
|
||||
explicitFlags[f.Name] = true
|
||||
})
|
||||
|
||||
// Load configuration from file if it exists
|
||||
// Load configuration from file if it exists. Capture the path before
|
||||
// applying: a saved "dir" option can change miniDataFolders.
|
||||
optionsFile := miniOptionsFilePath(*miniDataFolders)
|
||||
configOptions, err := loadMiniConfigurationFile(*miniDataFolders)
|
||||
if err != nil {
|
||||
glog.Warningf("Error loading configuration file: %v", err)
|
||||
}
|
||||
// Apply loaded options to flags (CLI flags will override these)
|
||||
applyConfigFileOptions(configOptions)
|
||||
// Apply loaded options to flags (explicitly provided flags win)
|
||||
appliedOptions := applyConfigFileOptions(configOptions)
|
||||
if len(appliedOptions) > 0 {
|
||||
pairs := make([]string, 0, len(appliedOptions))
|
||||
for k, v := range appliedOptions {
|
||||
pairs = append(pairs, k+"="+v)
|
||||
}
|
||||
sort.Strings(pairs)
|
||||
fmt.Printf(" Applying %d saved option(s) from %s: %s\n", len(appliedOptions), optionsFile, strings.Join(pairs, " "))
|
||||
}
|
||||
|
||||
if *miniOptions.debug {
|
||||
grace.StartDebugServer(*miniOptions.debugPort)
|
||||
@@ -1346,10 +1446,14 @@ func runMini(cmd *Command, args []string) bool {
|
||||
glog.Fatalf("Check Meta Folder (-dir=\"%s\") Writable: %s", *miniMasterOptions.metaFolder, err)
|
||||
}
|
||||
miniFilerOptions.defaultLevelDbDirectory = miniMasterOptions.metaFolder
|
||||
warnIfFilerStoreOutsideDir(*miniDataFolders, *miniMasterOptions.metaFolder)
|
||||
|
||||
// Calculate and set optimal volume size limit based on available disk space
|
||||
// Only auto-calculate if user didn't explicitly specify a value via -master.volumeSizeLimitMB
|
||||
if !isFlagPassed("master.volumeSizeLimitMB") {
|
||||
// Calculate and set optimal volume size limit based on available disk space.
|
||||
// Skip when the user passed -master.volumeSizeLimitMB explicitly, and when
|
||||
// the value was loaded from mini.options — file-applied flags are not in
|
||||
// Visit, and the saved pin must survive restarts.
|
||||
_, sizeLimitFromFile := appliedOptions["master.volumeSizeLimitMB"]
|
||||
if !isFlagPassed("master.volumeSizeLimitMB") && !sizeLimitFromFile {
|
||||
// User didn't override, use auto-calculated value
|
||||
// The -dir flag can accept comma-separated directories; use the first one for disk space calculation.
|
||||
// miniDataFolders was already tilde-resolved at the top of runMini.
|
||||
@@ -1524,13 +1628,30 @@ func startMiniServices(miniWhiteList []string, allServicesReady chan struct{}) {
|
||||
if miniProgressBoard != nil {
|
||||
miniProgressBoard.starting("Iceberg")
|
||||
}
|
||||
waitForServiceReady("Iceberg", *miniS3Options.portIceberg, bindIp)
|
||||
// Probe only when the catalog listener actually bound; otherwise the
|
||||
// port may answer as an unrelated process and get advertised as the
|
||||
// catalog.
|
||||
if miniS3Options.icebergBound.Load() {
|
||||
waitForServiceReady("Iceberg", *miniS3Options.portIceberg, bindIp)
|
||||
} else {
|
||||
if miniProgressBoard != nil {
|
||||
miniProgressBoard.failed("Iceberg")
|
||||
}
|
||||
glog.Warningf("Iceberg catalog on port %d did not start; see S3 startup log", *miniS3Options.portIceberg)
|
||||
}
|
||||
}
|
||||
if miniS3Options.portLance != nil && *miniS3Options.portLance > 0 {
|
||||
if miniProgressBoard != nil {
|
||||
miniProgressBoard.starting("Lance")
|
||||
}
|
||||
waitForServiceReady("Lance", *miniS3Options.portLance, bindIp)
|
||||
if miniS3Options.lanceBound.Load() {
|
||||
waitForServiceReady("Lance", *miniS3Options.portLance, bindIp)
|
||||
} else {
|
||||
if miniProgressBoard != nil {
|
||||
miniProgressBoard.failed("Lance")
|
||||
}
|
||||
glog.Warningf("Lance namespace server on port %d did not start; see S3 startup log", *miniS3Options.portLance)
|
||||
}
|
||||
}
|
||||
}
|
||||
if *miniEnableWebDAV {
|
||||
@@ -1745,10 +1866,10 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) {
|
||||
// the admin UI would otherwise print an endpoint nothing is listening on.
|
||||
var icebergPort, lancePort int
|
||||
if miniEnableS3 != nil && *miniEnableS3 {
|
||||
if miniS3Options.portIceberg != nil {
|
||||
if miniS3Options.portIceberg != nil && *miniS3Options.portIceberg > 0 && miniS3Options.icebergBound.Load() {
|
||||
icebergPort = *miniS3Options.portIceberg
|
||||
}
|
||||
if miniS3Options.portLance != nil {
|
||||
if miniS3Options.portLance != nil && *miniS3Options.portLance > 0 && miniS3Options.lanceBound.Load() {
|
||||
lancePort = *miniS3Options.portLance
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -12,6 +12,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -87,6 +88,14 @@ type S3Options struct {
|
||||
// its HTTP/gRPC servers once the ctx is cancelled, in addition to on
|
||||
// interrupt. Used by weed mini to orchestrate an ordered shutdown.
|
||||
shutdownCtx context.Context
|
||||
|
||||
// icebergBound/lanceBound record whether the auxiliary catalog listeners
|
||||
// bound their ports. The aux binds happen before the S3 listener serves,
|
||||
// so once the S3 port answers these are final; mini reads them to probe
|
||||
// and advertise only catalogs that are actually running instead of
|
||||
// whatever process may hold the configured port.
|
||||
icebergBound atomic.Bool
|
||||
lanceBound atomic.Bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -114,7 +123,7 @@ func init() {
|
||||
s3StandaloneOptions.metricsHttpPort = cmdS3.Flag.Int("metricsPort", 0, "Prometheus metrics listen port")
|
||||
s3StandaloneOptions.metricsHttpIp = cmdS3.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||
cmdS3.Flag.Bool("allowEmptyFolder", true, "deprecated, ignored. Empty folder cleanup is now automatic.")
|
||||
s3StandaloneOptions.allowDeleteBucketNotEmpty = cmdS3.Flag.Bool("allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
s3StandaloneOptions.allowDeleteBucketNotEmpty = cmdS3.Flag.Bool("allowDeleteBucketNotEmpty", false, "allow recursive deleting all entries along with bucket")
|
||||
s3StandaloneOptions.autoCreateBucket = cmdS3.Flag.Bool("autoCreateBucket", true, "create the bucket on upload if it does not exist, for admin identities only")
|
||||
s3StandaloneOptions.localFilerSocket = cmdS3.Flag.String("localFilerSocket", "", "local filer socket path")
|
||||
s3StandaloneOptions.localSocket = cmdS3.Flag.String("localSocket", "", "default to /tmp/seaweedfs-s3-<port>.sock")
|
||||
@@ -399,12 +408,16 @@ func (s3opt *S3Options) startS3Server() bool {
|
||||
|
||||
// Start Iceberg REST Catalog server if enabled
|
||||
if *s3opt.portIceberg > 0 {
|
||||
httpServers = append(httpServers, s3opt.startIcebergServer(s3ApiServer))
|
||||
if httpS := s3opt.startIcebergServer(s3ApiServer); httpS != nil {
|
||||
httpServers = append(httpServers, httpS)
|
||||
}
|
||||
}
|
||||
|
||||
// Start Lance Namespace server if enabled
|
||||
if s3opt.portLance != nil && *s3opt.portLance > 0 {
|
||||
httpServers = append(httpServers, s3opt.startLanceServer(s3ApiServer))
|
||||
if httpS := s3opt.startLanceServer(s3ApiServer); httpS != nil {
|
||||
httpServers = append(httpServers, httpS)
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
@@ -601,8 +614,12 @@ func (s3opt *S3Options) startIcebergServer(s3ApiServer *s3api.S3ApiServer) *http
|
||||
icebergListener, icebergLocalListener, err := util.NewIpAndLocalListeners(
|
||||
*s3opt.bindIp, *s3opt.portIceberg, time.Duration(*s3opt.idleTimeout)*time.Second)
|
||||
if err != nil {
|
||||
glog.Fatalf("Iceberg REST Catalog listener on %s error: %v", listenAddress, err)
|
||||
// The catalog is auxiliary to the S3 gateway; a port conflict must not
|
||||
// take the whole server down.
|
||||
glog.Errorf("Iceberg REST Catalog listener on %s error: %v", listenAddress, err)
|
||||
return nil
|
||||
}
|
||||
s3opt.icebergBound.Store(true)
|
||||
|
||||
glog.V(0).Infof("Start Iceberg REST Catalog Server at http://%s", listenAddress)
|
||||
|
||||
@@ -617,7 +634,7 @@ func (s3opt *S3Options) startIcebergServer(s3ApiServer *s3api.S3ApiServer) *http
|
||||
}
|
||||
go func() {
|
||||
if err := httpS.Serve(icebergListener); err != nil && err != http.ErrServerClosed {
|
||||
glog.Fatalf("Iceberg REST Catalog Server Fail to serve: %v", err)
|
||||
glog.Errorf("Iceberg REST Catalog Server Fail to serve: %v", err)
|
||||
}
|
||||
}()
|
||||
return httpS
|
||||
@@ -643,8 +660,10 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) *http.S
|
||||
lanceListener, lanceLocalListener, err := util.NewIpAndLocalListeners(
|
||||
*s3opt.bindIp, *s3opt.portLance, time.Duration(*s3opt.idleTimeout)*time.Second)
|
||||
if err != nil {
|
||||
glog.Fatalf("Lance Namespace listener on %s error: %v", listenAddress, err)
|
||||
glog.Errorf("Lance Namespace listener on %s error: %v", listenAddress, err)
|
||||
return nil
|
||||
}
|
||||
s3opt.lanceBound.Store(true)
|
||||
|
||||
glog.V(0).Infof("Start Lance Namespace Server at http://%s", listenAddress)
|
||||
|
||||
@@ -658,7 +677,7 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) *http.S
|
||||
}
|
||||
go func() {
|
||||
if err := httpS.Serve(lanceListener); err != nil && err != http.ErrServerClosed {
|
||||
glog.Fatalf("Lance Namespace Server Fail to serve: %v", err)
|
||||
glog.Errorf("Lance Namespace Server Fail to serve: %v", err)
|
||||
}
|
||||
}()
|
||||
return httpS
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
)
|
||||
|
||||
func holdPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// An occupied auxiliary catalog port must not take the S3 gateway down, must
|
||||
// return no server for shutdown registration, and must not report the catalog
|
||||
// as bound for mini's readiness checks.
|
||||
func TestAuxListenerBindFailureIsNonFatal(t *testing.T) {
|
||||
icebergPort := holdPort(t)
|
||||
lancePort := holdPort(t)
|
||||
|
||||
bind := "127.0.0.1"
|
||||
empty := ""
|
||||
zero := 0
|
||||
idle := 10
|
||||
s3opt := &S3Options{
|
||||
bindIp: &bind,
|
||||
ip: &bind,
|
||||
port: &zero,
|
||||
portHttps: &zero,
|
||||
portIceberg: &icebergPort,
|
||||
portLance: &lancePort,
|
||||
idleTimeout: &idle,
|
||||
icebergCredentialRole: &empty,
|
||||
externalUrl: &empty,
|
||||
tlsPrivateKey: &empty,
|
||||
}
|
||||
s3a := &s3api.S3ApiServer{}
|
||||
|
||||
if s := s3opt.startIcebergServer(s3a); s != nil {
|
||||
s.Close()
|
||||
t.Error("startIcebergServer on an occupied port should return nil")
|
||||
}
|
||||
if s3opt.icebergBound.Load() {
|
||||
t.Error("icebergBound must stay false when the listener failed to bind")
|
||||
}
|
||||
if s := s3opt.startLanceServer(s3a); s != nil {
|
||||
s.Close()
|
||||
t.Error("startLanceServer on an occupied port should return nil")
|
||||
}
|
||||
if s3opt.lanceBound.Load() {
|
||||
t.Error("lanceBound must stay false when the listener failed to bind")
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,8 @@ func TestDeriveS3AdvertisedEndpoint(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
for i := range tests {
|
||||
tc := &tests[i]
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.opt.deriveS3AdvertisedEndpoint()
|
||||
if got != tc.want {
|
||||
|
||||
@@ -149,6 +149,7 @@ func init() {
|
||||
serverOptions.v.readMode = cmdServer.Flag.String("volume.readMode", "proxy", "[local|proxy|redirect] how to deal with non-local volume: 'not found|read in remote node|redirect volume location'.")
|
||||
serverOptions.v.compactionMBPerSecond = cmdServer.Flag.Int("volume.compactionMBps", 0, "limit compaction speed in mega bytes per second")
|
||||
serverOptions.v.maintenanceMBPerSecond = cmdServer.Flag.Int("volume.maintenanceMBps", 0, "limit maintenance (replication / balance) IO rate in MB/s. Unset is 0, no limitation.")
|
||||
serverOptions.v.maxParallelVacuumCommits = cmdServer.Flag.Int("volume.compactionMaxParallelCommits", 0, "limit how many vacuum commits run at once on this server; queued commits hold no volume lock. 0 means unlimited")
|
||||
serverOptions.v.fileSizeLimitMB = cmdServer.Flag.Int("volume.fileSizeLimitMB", 256, "limit file size to avoid out of memory")
|
||||
serverOptions.v.ldbTimeout = cmdServer.Flag.Int64("volume.index.leveldbTimeout", 0, "alive time for leveldb (default to 0). If leveldb of volume is not accessed in ldbTimeout hours, it will be off loaded to reduce opened files and memory consumption.")
|
||||
serverOptions.v.concurrentUploadLimitMB = cmdServer.Flag.Int("volume.concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
@@ -182,7 +183,7 @@ func init() {
|
||||
s3Options.iamConfig = cmdServer.Flag.String("s3.iam.config", "", "path to the advanced IAM config file for S3. Overrides -iam.config if both are provided.")
|
||||
s3Options.auditLogConfig = cmdServer.Flag.String("s3.auditLogConfig", "", "path to the audit log config file")
|
||||
cmdServer.Flag.Bool("s3.allowEmptyFolder", true, "deprecated, ignored. Empty folder cleanup is now automatic.")
|
||||
s3Options.allowDeleteBucketNotEmpty = cmdServer.Flag.Bool("s3.allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket")
|
||||
s3Options.allowDeleteBucketNotEmpty = cmdServer.Flag.Bool("s3.allowDeleteBucketNotEmpty", false, "allow recursive deleting all entries along with bucket")
|
||||
s3Options.autoCreateBucket = cmdServer.Flag.Bool("s3.autoCreateBucket", true, "create the bucket on upload if it does not exist, for admin identities only")
|
||||
s3Options.localSocket = cmdServer.Flag.String("s3.localSocket", "", "default to /tmp/seaweedfs-s3-<port>.sock")
|
||||
s3Options.bindIp = cmdServer.Flag.String("s3.ip.bind", "", "ip address to bind to. If empty, default to same as -ip.bind option.")
|
||||
|
||||
@@ -61,6 +61,7 @@ type VolumeServerOptions struct {
|
||||
memProfile *string
|
||||
compactionMBPerSecond *int
|
||||
maintenanceMBPerSecond *int
|
||||
maxParallelVacuumCommits *int
|
||||
fileSizeLimitMB *int
|
||||
concurrentUploadLimitMB *int
|
||||
concurrentDownloadLimitMB *int
|
||||
@@ -215,6 +216,7 @@ func init() {
|
||||
v.memProfile = cmdVolume.Flag.String("memprofile", "", "memory profile output file")
|
||||
v.compactionMBPerSecond = cmdVolume.Flag.Int("compactionMBps", 0, "limit background compaction or copying speed in mega bytes per second")
|
||||
v.maintenanceMBPerSecond = cmdVolume.Flag.Int("maintenanceMBps", 0, "limit maintenance (replication / balance) IO rate in MB/s. Unset is 0, no limitation.")
|
||||
v.maxParallelVacuumCommits = cmdVolume.Flag.Int("compactionMaxParallelCommits", 0, "limit how many vacuum commits run at once on this server; queued commits hold no volume lock. 0 means unlimited")
|
||||
v.fileSizeLimitMB = cmdVolume.Flag.Int("fileSizeLimitMB", 256, "limit file size to avoid out of memory")
|
||||
v.ldbTimeout = cmdVolume.Flag.Int64("index.leveldbTimeout", 0, "alive time for leveldb (default to 0). If leveldb of volume is not accessed in ldbTimeout hours, it will be off loaded to reduce opened files and memory consumption.")
|
||||
v.concurrentUploadLimitMB = cmdVolume.Flag.Int("concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
|
||||
@@ -447,6 +449,7 @@ func (v VolumeServerOptions) startVolumeServer(volumeFolders, maxVolumeCounts, v
|
||||
*v.fixJpgOrientation, *v.readMode,
|
||||
*v.compactionMBPerSecond,
|
||||
*v.maintenanceMBPerSecond,
|
||||
*v.maxParallelVacuumCommits,
|
||||
*v.fileSizeLimitMB,
|
||||
int64(*v.concurrentUploadLimitMB)*1024*1024,
|
||||
int64(*v.concurrentDownloadLimitMB)*1024*1024,
|
||||
|
||||
@@ -82,7 +82,7 @@ func runPluginWorkerWithOptions(options pluginWorkerRunOptions) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir)
|
||||
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir, options.Address)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to build plugin worker handlers: %v", err)
|
||||
return false
|
||||
@@ -141,7 +141,7 @@ func resolvePluginWorkerID(explicitID string, workingDir string) (string, error)
|
||||
// buildPluginWorkerHandlers resolves the comma-separated jobTypes string
|
||||
// (which may contain category names like "all", "default", "heavy" and/or
|
||||
// explicit job type names/aliases) into a deduplicated slice of JobHandlers.
|
||||
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string) ([]pluginworker.JobHandler, error) {
|
||||
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string, workerAddress ...string) ([]pluginworker.JobHandler, error) {
|
||||
jobTypes = strings.TrimSpace(jobTypes)
|
||||
if jobTypes == "" {
|
||||
jobTypes = defaultPluginWorkerJobTypes
|
||||
@@ -152,10 +152,16 @@ func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxE
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addr := ""
|
||||
if len(workerAddress) > 0 {
|
||||
addr = strings.TrimSpace(workerAddress[0])
|
||||
}
|
||||
|
||||
opts := pluginworker.HandlerBuildOptions{
|
||||
GrpcDialOption: dialOption,
|
||||
MaxExecute: maxExecute,
|
||||
WorkingDir: workingDir,
|
||||
WorkerAddress: addr,
|
||||
}
|
||||
|
||||
handlers := make([]pluginworker.JobHandler, 0, len(factories))
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// ecBalanceFakeServer answers the EC-shard RPC subset a balance run drives:
|
||||
// copy (rejected when full), mount, inventory, unmount, delete. The disk state
|
||||
// a real server tracks lives in shards; what the planner saw is rebuilt from it
|
||||
// through FetchTopology, so a landed move is visible to the next plan.
|
||||
type ecBalanceFakeServer struct {
|
||||
volume_server_pb.UnimplementedVolumeServerServer
|
||||
|
||||
mu sync.Mutex
|
||||
shards map[uint32]map[uint32]bool // vid -> shardId -> present
|
||||
full bool // VolumeEcShardsCopy answers "no space left"
|
||||
copyCalls int
|
||||
grpcAddr string
|
||||
stop func()
|
||||
}
|
||||
|
||||
func (f *ecBalanceFakeServer) VolumeEcShardsCopy(ctx context.Context, req *volume_server_pb.VolumeEcShardsCopyRequest) (*volume_server_pb.VolumeEcShardsCopyResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.copyCalls++
|
||||
if f.full {
|
||||
return nil, fmt.Errorf("no space left")
|
||||
}
|
||||
return &volume_server_pb.VolumeEcShardsCopyResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *ecBalanceFakeServer) VolumeEcShardsMount(ctx context.Context, req *volume_server_pb.VolumeEcShardsMountRequest) (*volume_server_pb.VolumeEcShardsMountResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, sid := range req.ShardIds {
|
||||
f.shards[req.VolumeId][sid] = true
|
||||
}
|
||||
return &volume_server_pb.VolumeEcShardsMountResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *ecBalanceFakeServer) VolumeEcShardsInfo(ctx context.Context, req *volume_server_pb.VolumeEcShardsInfoRequest) (*volume_server_pb.VolumeEcShardsInfoResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
resp := &volume_server_pb.VolumeEcShardsInfoResponse{}
|
||||
for sid := range f.shards[req.VolumeId] {
|
||||
resp.EcShardInfos = append(resp.EcShardInfos, &volume_server_pb.EcShardInfo{
|
||||
VolumeId: req.VolumeId,
|
||||
ShardId: sid,
|
||||
Size: 1,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (f *ecBalanceFakeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_server_pb.VolumeEcShardsUnmountRequest) (*volume_server_pb.VolumeEcShardsUnmountResponse, error) {
|
||||
return &volume_server_pb.VolumeEcShardsUnmountResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *ecBalanceFakeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, sid := range req.ShardIds {
|
||||
delete(f.shards[req.VolumeId], sid)
|
||||
}
|
||||
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
|
||||
}
|
||||
|
||||
// nodeId returns the "host:httpPort.grpcPort" node id form the cluster uses, so
|
||||
// ToGrpcAddress resolves to this server's listener.
|
||||
func (f *ecBalanceFakeServer) nodeId() string {
|
||||
host, port, err := net.SplitHostPort(f.grpcAddr)
|
||||
if err != nil {
|
||||
return f.grpcAddr
|
||||
}
|
||||
return fmt.Sprintf("%s:1.%s", host, port)
|
||||
}
|
||||
|
||||
func (f *ecBalanceFakeServer) shardCount(vid uint32) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.shards[vid])
|
||||
}
|
||||
|
||||
func startEcBalanceFakeServer(t *testing.T, vid uint32, present ...uint32) *ecBalanceFakeServer {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
f := &ecBalanceFakeServer{
|
||||
shards: map[uint32]map[uint32]bool{vid: {}},
|
||||
grpcAddr: listener.Addr().String(),
|
||||
}
|
||||
for _, sid := range present {
|
||||
f.shards[vid][sid] = true
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
volume_server_pb.RegisterVolumeServerServer(server, f)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
f.stop = server.Stop
|
||||
t.Cleanup(server.Stop)
|
||||
return f
|
||||
}
|
||||
|
||||
// fetchEcBalanceTopology builds a FetchTopology that reports the fake servers'
|
||||
// live shard inventory.
|
||||
func fetchEcBalanceTopology(servers []*ecBalanceFakeServer) func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error) {
|
||||
return func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error) {
|
||||
var nodes []*master_pb.DataNodeInfo
|
||||
for _, f := range servers {
|
||||
di := &master_pb.DiskInfo{Type: string(types.HardDriveType), MaxVolumeCount: 100}
|
||||
f.mu.Lock()
|
||||
for v, set := range f.shards {
|
||||
if len(set) == 0 {
|
||||
continue
|
||||
}
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
for sid := range set {
|
||||
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(sid), 1))
|
||||
}
|
||||
di.EcShardInfos = append(di.EcShardInfos, &master_pb.VolumeEcShardInformationMessage{
|
||||
Id: v,
|
||||
Collection: "c1",
|
||||
EcIndexBits: si.Bitmap(),
|
||||
ShardSizes: si.SizesInt64(),
|
||||
DiskType: string(types.HardDriveType),
|
||||
})
|
||||
}
|
||||
f.mu.Unlock()
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: f.nodeId(),
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{string(types.HardDriveType): di},
|
||||
})
|
||||
}
|
||||
return &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "r1",
|
||||
DataNodeInfos: nodes,
|
||||
}},
|
||||
}},
|
||||
}, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcBalanceReplanAfterNoSpaceLeft reproduces seaweedfs/seaweedfs#11753:
|
||||
// the planner works from heartbeat free-slot counts that can disagree with the
|
||||
// volume server's own check, so a planned target can reject the copy with
|
||||
// "no space left" mid-run. The balance must re-plan without that node as a
|
||||
// target rather than fail the whole run.
|
||||
func TestEcBalanceReplanAfterNoSpaceLeft(t *testing.T) {
|
||||
const vid = uint32(1)
|
||||
var allShards []uint32
|
||||
for sid := 0; sid < erasure_coding.DataShardsCount+erasure_coding.ParityShardsCount; sid++ {
|
||||
allShards = append(allShards, uint32(sid))
|
||||
}
|
||||
|
||||
full := startEcBalanceFakeServer(t, vid)
|
||||
accept := startEcBalanceFakeServer(t, vid)
|
||||
source := startEcBalanceFakeServer(t, vid, allShards...)
|
||||
full.full = true
|
||||
|
||||
env := &Env{
|
||||
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
FetchTopology: fetchEcBalanceTopology([]*ecBalanceFakeServer{source, full, accept}),
|
||||
}
|
||||
|
||||
err := EcBalance(env, []string{"c1"}, "", nil, types.HardDriveType, 3, 0, true, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("balance should re-plan around the full target, got: %v", err)
|
||||
}
|
||||
if full.copyCalls == 0 {
|
||||
t.Fatalf("expected the full server to have rejected at least one copy")
|
||||
}
|
||||
if got := full.shardCount(vid); got != 0 {
|
||||
t.Fatalf("full server should receive no shards, holds %d", got)
|
||||
}
|
||||
total := source.shardCount(vid) + full.shardCount(vid) + accept.shardCount(vid)
|
||||
if total != len(allShards) {
|
||||
t.Fatalf("shards lost or duplicated: total %d, want %d", total, len(allShards))
|
||||
}
|
||||
if source.shardCount(vid) == len(allShards) {
|
||||
t.Fatalf("no shards were moved off the source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcBalanceReplanAfterNoSpaceLeftStaleSnapshot serves the pre-move
|
||||
// topology on every collection after the first, simulating a master heartbeat
|
||||
// that lags the landed moves. The re-plan must replay its completed moves onto
|
||||
// the stale snapshot instead of re-moving shards off the deleted source.
|
||||
func TestEcBalanceReplanAfterNoSpaceLeftStaleSnapshot(t *testing.T) {
|
||||
const vid = uint32(1)
|
||||
var allShards []uint32
|
||||
for sid := 0; sid < erasure_coding.DataShardsCount+erasure_coding.ParityShardsCount; sid++ {
|
||||
allShards = append(allShards, uint32(sid))
|
||||
}
|
||||
|
||||
full := startEcBalanceFakeServer(t, vid)
|
||||
accept := startEcBalanceFakeServer(t, vid)
|
||||
source := startEcBalanceFakeServer(t, vid, allShards...)
|
||||
full.full = true
|
||||
|
||||
live := fetchEcBalanceTopology([]*ecBalanceFakeServer{source, full, accept})
|
||||
var stale *master_pb.TopologyInfo
|
||||
calls := 0
|
||||
fetchTopology := func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error) {
|
||||
topo, size, err := live(delay)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
calls++
|
||||
if stale == nil {
|
||||
stale = topo
|
||||
}
|
||||
if calls > 1 {
|
||||
return stale, size, nil
|
||||
}
|
||||
return topo, size, nil
|
||||
}
|
||||
|
||||
env := &Env{
|
||||
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
FetchTopology: fetchTopology,
|
||||
}
|
||||
|
||||
err := EcBalance(env, []string{"c1"}, "", nil, types.HardDriveType, 3, 0, true, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("balance should re-plan around the full target, got: %v", err)
|
||||
}
|
||||
if calls < 2 {
|
||||
t.Fatalf("expected a re-plan, topology collected %d time(s)", calls)
|
||||
}
|
||||
// Each shard lands on the accepting server exactly once; a stale re-plan
|
||||
// would copy already-moved shards again.
|
||||
if got, want := accept.copyCalls, accept.shardCount(vid); got != want {
|
||||
t.Fatalf("accepting server saw %d copies for %d held shards: completed moves were re-planned", got, want)
|
||||
}
|
||||
total := source.shardCount(vid) + full.shardCount(vid) + accept.shardCount(vid)
|
||||
if total != len(allShards) {
|
||||
t.Fatalf("shards lost or duplicated: total %d, want %d", total, len(allShards))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcBalanceNoReceivingTargetFails surfaces the refusal when every
|
||||
// destination that could take a shard has rejected a copy, rather than
|
||||
// reporting success for a plan that moved nothing.
|
||||
func TestEcBalanceNoReceivingTargetFails(t *testing.T) {
|
||||
const vid = uint32(1)
|
||||
var allShards []uint32
|
||||
for sid := 0; sid < erasure_coding.DataShardsCount+erasure_coding.ParityShardsCount; sid++ {
|
||||
allShards = append(allShards, uint32(sid))
|
||||
}
|
||||
|
||||
full := startEcBalanceFakeServer(t, vid)
|
||||
source := startEcBalanceFakeServer(t, vid, allShards...)
|
||||
full.full = true
|
||||
|
||||
env := &Env{
|
||||
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
FetchTopology: fetchEcBalanceTopology([]*ecBalanceFakeServer{source, full}),
|
||||
}
|
||||
|
||||
err := EcBalance(env, []string{"c1"}, "", nil, types.HardDriveType, 3, 0, true, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("balance with no receiving target should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no space left") {
|
||||
t.Fatalf("expected the refusal to surface, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyCompletedMovesReplaysDiskState checks that replaying a landed move
|
||||
// onto a stale snapshot carries the shard's disk attribution and the per-disk
|
||||
// slot accounting, so a re-plan does not treat the just-filled disk as empty.
|
||||
func TestApplyCompletedMovesReplaysDiskState(t *testing.T) {
|
||||
const vid = uint32(1)
|
||||
|
||||
source := newEcNode("dc1", "r1", "dn1", 99)
|
||||
source.Info.DiskInfos["hdd"] = &master_pb.DiskInfo{
|
||||
Type: "hdd",
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
|
||||
Id: vid,
|
||||
Collection: "c1",
|
||||
DiskId: 1,
|
||||
DiskType: "hdd",
|
||||
EcIndexBits: uint32(erasure_coding.ShardBits(0).Set(5)),
|
||||
}},
|
||||
}
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
si.Set(erasure_coding.NewShardInfo(5, 1))
|
||||
source.Disks = map[uint32]*EcDisk{
|
||||
1: {DiskId: 1, DiskType: "hdd", FreeEcSlots: 99, EcShardCount: 1, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{1: si}},
|
||||
}
|
||||
|
||||
target := newEcNode("dc1", "r1", "dn2", 100)
|
||||
target.Disks = map[uint32]*EcDisk{
|
||||
1: {DiskId: 1, DiskType: "hdd", FreeEcSlots: 10, EcShardCount: 0, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{}},
|
||||
2: {DiskId: 2, DiskType: "hdd", FreeEcSlots: 10, EcShardCount: 0, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{}},
|
||||
}
|
||||
|
||||
nodes := []*EcNode{source, target}
|
||||
move := ecbalancer.Move{
|
||||
VolumeID: vid,
|
||||
ShardID: 5,
|
||||
Collection: "c1",
|
||||
SourceNode: "dn1",
|
||||
TargetNode: "dn2",
|
||||
TargetDisk: 2,
|
||||
Phase: "within_rack",
|
||||
}
|
||||
applyCompletedMoves(nodes, []ecbalancer.Move{move}, types.HardDriveType)
|
||||
|
||||
if ecNodeHasShard(source, needle.VolumeId(vid), 5) {
|
||||
t.Fatalf("source still holds shard 5")
|
||||
}
|
||||
if d := source.Disks[1]; d.EcShardCount != 0 || d.FreeEcSlots != 100 {
|
||||
t.Fatalf("source disk counts not restored: %+v", d)
|
||||
}
|
||||
if !ecNodeHasShard(target, needle.VolumeId(vid), 5) {
|
||||
t.Fatalf("target missing shard 5")
|
||||
}
|
||||
if d := target.Disks[2]; d.EcShardCount != 1 || d.FreeEcSlots != 9 {
|
||||
t.Fatalf("target disk 2 not charged: %+v", d)
|
||||
}
|
||||
if d := target.Disks[1]; d.EcShardCount != 0 {
|
||||
t.Fatalf("shard attributed to wrong disk: %+v", d)
|
||||
}
|
||||
if target.FreeEcSlot != 99 {
|
||||
t.Fatalf("target free slots = %d, want 99", target.FreeEcSlot)
|
||||
}
|
||||
|
||||
// Replaying again (or onto a fresher snapshot) must not double-count.
|
||||
applyCompletedMoves(nodes, []ecbalancer.Move{move}, types.HardDriveType)
|
||||
if d := target.Disks[2]; d.EcShardCount != 1 || d.FreeEcSlots != 9 {
|
||||
t.Fatalf("double-applied move: %+v", d)
|
||||
}
|
||||
if target.FreeEcSlot != 99 {
|
||||
t.Fatalf("target free slots = %d, want 99", target.FreeEcSlot)
|
||||
}
|
||||
}
|
||||
+310
-49
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
@@ -762,6 +763,26 @@ type ecBalancer struct {
|
||||
// migratingVolumeIds are mid-encode volumes whose shards are ingested from
|
||||
// every disk-type bucket; see EcBalance.
|
||||
migratingVolumeIds map[uint32]bool
|
||||
// noReceiveTargets are node ids the planner may not pick as destinations;
|
||||
// noReceiveDisks narrows that to individual disks on a node: a server-side
|
||||
// auto-pick refusal means no disk qualifies, but an explicit-disk refusal
|
||||
// only convicts that disk. See EcBalance.
|
||||
noReceiveTargets map[string]bool
|
||||
noReceiveDisks map[string]map[uint32]bool
|
||||
// mu guards the execution journal written by concurrent move tasks.
|
||||
mu sync.Mutex
|
||||
// rejectedTargets and rejectedDisks collect the destinations that refused a
|
||||
// copy this attempt; completedMoves records applied moves so a retry can
|
||||
// replay them onto a stale-heartbeat snapshot; sawOtherErr marks any
|
||||
// failure that is not a "no space left" refusal and makes the run
|
||||
// non-retryable.
|
||||
rejectedTargets map[string]bool
|
||||
rejectedDisks map[string]map[uint32]bool
|
||||
completedMoves []ecbalancer.Move
|
||||
sawOtherErr bool
|
||||
// plannedMoves is the move count of the last plan, for the empty-replan
|
||||
// check in EcBalance.
|
||||
plannedMoves int
|
||||
}
|
||||
|
||||
// excludeNodes is a set of server addresses kept out of the balance as copy/move
|
||||
@@ -782,32 +803,6 @@ type ecBalancer struct {
|
||||
// so a plain ec.balance -diskType=X never drags deliberately tiered shards of
|
||||
// other types onto X disks.
|
||||
func EcBalance(env *Env, collections []string, dc string, ecReplicaPlacement *super_block.ReplicaPlacement, diskType types.DiskType, maxParallelization int, ioBytePerSecond int64, applyBalancing bool, excludeNodes map[pb.ServerAddress]struct{}, volumeIds []needle.VolumeId, migratingVolumeIds []needle.VolumeId) (err error) {
|
||||
// collect all ec nodes
|
||||
allEcNodes, totalFreeEcSlots, err := CollectEcNodesForDC(env, dc, diskType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop excluded nodes (and the slots they contribute) before planning so they
|
||||
// can be neither a target nor a source for any move this balance plans.
|
||||
if len(excludeNodes) > 0 {
|
||||
kept := allEcNodes[:0]
|
||||
var excludedFreeSlots int
|
||||
for _, en := range allEcNodes {
|
||||
if _, skip := excludeNodes[pb.NewServerAddressFromDataNode(en.Info)]; skip {
|
||||
excludedFreeSlots += en.FreeEcSlot
|
||||
glog.V(0).Infof("EC balance excluding node %s: skipped as unreachable by the encode orphan sweep", en.Info.Id)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, en)
|
||||
}
|
||||
allEcNodes = kept
|
||||
totalFreeEcSlots -= excludedFreeSlots
|
||||
}
|
||||
|
||||
if totalFreeEcSlots < 1 {
|
||||
return fmt.Errorf("no free ec shard slots. only %d left", totalFreeEcSlots)
|
||||
}
|
||||
|
||||
var volumeIdFilter map[uint32]bool
|
||||
if len(volumeIds) > 0 {
|
||||
@@ -824,22 +819,277 @@ func EcBalance(env *Env, collections []string, dc string, ecReplicaPlacement *su
|
||||
}
|
||||
}
|
||||
|
||||
ecb := &ecBalancer{
|
||||
env: env,
|
||||
ecNodes: allEcNodes,
|
||||
replicaPlacement: ecReplicaPlacement,
|
||||
applyBalancing: applyBalancing,
|
||||
maxParallelization: maxParallelization,
|
||||
ioBytePerSecond: ioBytePerSecond,
|
||||
diskType: diskType,
|
||||
volumeIds: volumeIdFilter,
|
||||
migratingVolumeIds: migrating,
|
||||
}
|
||||
// The planner's free-slot numbers come from heartbeat snapshots that can lag
|
||||
// the volume server's own accounting, so a planned target can refuse a copy
|
||||
// with "no space left" while shards move concurrently (issue #11753). The
|
||||
// server's refusal is authoritative: re-collect the topology, mark the
|
||||
// refusing targets no-receive, and plan again. They stay usable as sources.
|
||||
noReceiveTargets := make(map[string]bool)
|
||||
noReceiveDisks := make(map[string]map[uint32]bool)
|
||||
var completed []ecbalancer.Move
|
||||
var lastErr error
|
||||
for {
|
||||
// collect all ec nodes
|
||||
allEcNodes, totalFreeEcSlots, err := CollectEcNodesForDC(env, dc, diskType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(collections) == 0 {
|
||||
glog.V(1).Infof("WARNING: No collections to balance EC volumes across.\n")
|
||||
// Drop excluded nodes (and the slots they contribute) before planning so they
|
||||
// can be neither a target nor a source for any move this balance plans.
|
||||
if len(excludeNodes) > 0 {
|
||||
kept := allEcNodes[:0]
|
||||
var excludedFreeSlots int
|
||||
for _, en := range allEcNodes {
|
||||
if _, skip := excludeNodes[pb.NewServerAddressFromDataNode(en.Info)]; skip {
|
||||
excludedFreeSlots += en.FreeEcSlot
|
||||
glog.V(0).Infof("EC balance excluding node %s: skipped as unreachable by the encode orphan sweep", en.Info.Id)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, en)
|
||||
}
|
||||
allEcNodes = kept
|
||||
totalFreeEcSlots -= excludedFreeSlots
|
||||
}
|
||||
// A refused destination's slots cannot take a copy, so they do not
|
||||
// count toward the free-space precheck.
|
||||
for _, en := range allEcNodes {
|
||||
if noReceiveTargets[en.Info.Id] {
|
||||
totalFreeEcSlots -= en.FreeEcSlot
|
||||
continue
|
||||
}
|
||||
for diskId := range noReceiveDisks[en.Info.Id] {
|
||||
if d := en.Disks[diskId]; d != nil {
|
||||
totalFreeEcSlots -= d.FreeEcSlots
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalFreeEcSlots < 1 {
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("no receiving targets remain: %w", lastErr)
|
||||
}
|
||||
return fmt.Errorf("no free ec shard slots. only %d left", totalFreeEcSlots)
|
||||
}
|
||||
|
||||
// A fresh collection can still describe an applied move through a
|
||||
// heartbeat that predates its source delete; replay the journal.
|
||||
applyCompletedMoves(allEcNodes, completed, diskType)
|
||||
|
||||
ecb := &ecBalancer{
|
||||
env: env,
|
||||
ecNodes: allEcNodes,
|
||||
replicaPlacement: ecReplicaPlacement,
|
||||
applyBalancing: applyBalancing,
|
||||
maxParallelization: maxParallelization,
|
||||
ioBytePerSecond: ioBytePerSecond,
|
||||
diskType: diskType,
|
||||
volumeIds: volumeIdFilter,
|
||||
migratingVolumeIds: migrating,
|
||||
noReceiveTargets: noReceiveTargets,
|
||||
noReceiveDisks: noReceiveDisks,
|
||||
}
|
||||
|
||||
if len(collections) == 0 {
|
||||
glog.V(1).Infof("WARNING: No collections to balance EC volumes across.\n")
|
||||
}
|
||||
err = ecb.balance(collections)
|
||||
if !applyBalancing {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
if lastErr != nil && ecb.plannedMoves == 0 {
|
||||
return fmt.Errorf("no receiving targets remain: %w", lastErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replan while the failed attempt surfaced newly-refusing targets; a node
|
||||
// once marked can no longer be picked, so the loop terminates. Any other
|
||||
// failure aborts instead of being hidden by the retry.
|
||||
newRejects := ecb.mergeRejectedTargets(noReceiveTargets, noReceiveDisks)
|
||||
if len(newRejects) == 0 || ecb.hasOtherError() {
|
||||
return err
|
||||
}
|
||||
lastErr = err
|
||||
completed = append(completed, ecb.completedMoves...)
|
||||
fmt.Printf("ec.balance: %v reported no space left; re-collecting topology and re-planning without them as targets\n", newRejects)
|
||||
}
|
||||
}
|
||||
|
||||
// applyCompletedMoves replays already-applied moves onto a freshly collected
|
||||
// node list: heartbeats can lag, so the snapshot may still show a moved shard
|
||||
// on its deleted source or lack it on its mounted target.
|
||||
func applyCompletedMoves(ecNodes []*EcNode, moves []ecbalancer.Move, diskType types.DiskType) {
|
||||
if len(moves) == 0 {
|
||||
return
|
||||
}
|
||||
byID := make(map[string]*EcNode, len(ecNodes))
|
||||
for _, en := range ecNodes {
|
||||
byID[en.Info.Id] = en
|
||||
}
|
||||
for _, m := range moves {
|
||||
vid := needle.VolumeId(m.VolumeID)
|
||||
shardId := erasure_coding.ShardId(m.ShardID)
|
||||
if src := byID[m.SourceNode]; src != nil {
|
||||
removeEcShard(src, vid, shardId)
|
||||
}
|
||||
if m.Phase == "dedup" {
|
||||
continue
|
||||
}
|
||||
if dst := byID[m.TargetNode]; dst != nil && !ecNodeHasShard(dst, vid, shardId) {
|
||||
addEcShard(dst, m.Collection, vid, shardId, m.TargetDisk, diskType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ecNodeHasShard(ecNode *EcNode, vid needle.VolumeId, shardId erasure_coding.ShardId) bool {
|
||||
for _, diskInfo := range ecNode.Info.DiskInfos {
|
||||
for _, eci := range diskInfo.EcShardInfos {
|
||||
if needle.VolumeId(eci.Id) == vid && erasure_coding.ShardBits(eci.EcIndexBits).Has(shardId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// removeEcShard drops the shard from the node model, including the per-disk
|
||||
// bookkeeping of the disk that reported it.
|
||||
func removeEcShard(en *EcNode, vid needle.VolumeId, shardId erasure_coding.ShardId) {
|
||||
for _, diskInfo := range en.Info.DiskInfos {
|
||||
for _, eci := range diskInfo.EcShardInfos {
|
||||
if needle.VolumeId(eci.Id) != vid || !erasure_coding.ShardBits(eci.EcIndexBits).Has(shardId) {
|
||||
continue
|
||||
}
|
||||
if d := en.Disks[eci.DiskId]; d != nil {
|
||||
if si := d.EcShards[vid]; si != nil {
|
||||
si.Delete(shardId)
|
||||
}
|
||||
d.EcShardCount--
|
||||
d.FreeEcSlots++
|
||||
}
|
||||
}
|
||||
}
|
||||
en.DeleteEcVolumeShards(vid, []erasure_coding.ShardId{shardId})
|
||||
}
|
||||
|
||||
// addEcShard places the shard on the disk the move used when that disk is
|
||||
// known; otherwise (TargetDisk 0, server-picked) it falls back to the node's
|
||||
// disk-type bucket.
|
||||
func addEcShard(en *EcNode, collection string, vid needle.VolumeId, shardId erasure_coding.ShardId, diskId uint32, diskType types.DiskType) {
|
||||
d := en.Disks[diskId]
|
||||
if diskId == 0 || d == nil {
|
||||
en.AddEcVolumeShards(vid, collection, []erasure_coding.ShardId{shardId}, diskType)
|
||||
return
|
||||
}
|
||||
diskInfo := en.Info.DiskInfos[string(diskType)]
|
||||
if diskInfo == nil {
|
||||
diskInfo = &master_pb.DiskInfo{Type: string(diskType)}
|
||||
en.Info.DiskInfos[string(diskType)] = diskInfo
|
||||
}
|
||||
var eci *master_pb.VolumeEcShardInformationMessage
|
||||
for _, e := range diskInfo.EcShardInfos {
|
||||
if needle.VolumeId(e.Id) == vid && e.DiskId == diskId {
|
||||
eci = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if eci == nil {
|
||||
eci = &master_pb.VolumeEcShardInformationMessage{
|
||||
Id: uint32(vid),
|
||||
Collection: collection,
|
||||
DiskId: diskId,
|
||||
DiskType: string(diskType),
|
||||
}
|
||||
diskInfo.EcShardInfos = append(diskInfo.EcShardInfos, eci)
|
||||
}
|
||||
si := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(eci)
|
||||
before := si.Count()
|
||||
si.Set(erasure_coding.NewShardInfo(shardId, 0))
|
||||
eci.EcIndexBits = si.Bitmap()
|
||||
eci.ShardSizes = si.SizesInt64()
|
||||
added := si.Count() - before
|
||||
|
||||
en.FreeEcSlot -= added
|
||||
d.EcShardCount += added
|
||||
d.FreeEcSlots -= added
|
||||
if d.EcShards == nil {
|
||||
d.EcShards = make(map[needle.VolumeId]*erasure_coding.ShardsInfo)
|
||||
}
|
||||
if d.EcShards[vid] == nil {
|
||||
d.EcShards[vid] = erasure_coding.NewShardsInfo()
|
||||
}
|
||||
d.EcShards[vid].Set(erasure_coding.NewShardInfo(shardId, 0))
|
||||
}
|
||||
|
||||
// mergeRejectedTargets folds the destinations this run saw refuse into the
|
||||
// no-receive sets and returns the newly added ids; an empty result means no
|
||||
// progress is possible.
|
||||
func (ecb *ecBalancer) mergeRejectedTargets(noReceiveNodes map[string]bool, noReceiveDisks map[string]map[uint32]bool) (added []string) {
|
||||
ecb.mu.Lock()
|
||||
defer ecb.mu.Unlock()
|
||||
for id := range ecb.rejectedTargets {
|
||||
if !noReceiveNodes[id] {
|
||||
noReceiveNodes[id] = true
|
||||
added = append(added, id)
|
||||
}
|
||||
}
|
||||
for id, disks := range ecb.rejectedDisks {
|
||||
if noReceiveNodes[id] {
|
||||
continue
|
||||
}
|
||||
if noReceiveDisks[id] == nil {
|
||||
noReceiveDisks[id] = make(map[uint32]bool)
|
||||
}
|
||||
for diskId := range disks {
|
||||
if !noReceiveDisks[id][diskId] {
|
||||
noReceiveDisks[id][diskId] = true
|
||||
added = append(added, fmt.Sprintf("%s:disk%d", id, diskId))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
return
|
||||
}
|
||||
|
||||
func (ecb *ecBalancer) hasOtherError() bool {
|
||||
ecb.mu.Lock()
|
||||
defer ecb.mu.Unlock()
|
||||
return ecb.sawOtherErr
|
||||
}
|
||||
|
||||
// recordMoveResult journals an apply-mode move outcome: a success is replayed
|
||||
// onto the next attempt's snapshot, a "no space left" marks the target
|
||||
// no-receive, and any other failure makes the run non-retryable.
|
||||
func (ecb *ecBalancer) recordMoveResult(m ecbalancer.Move, err error) {
|
||||
ecb.mu.Lock()
|
||||
defer ecb.mu.Unlock()
|
||||
switch {
|
||||
case err == nil:
|
||||
ecb.completedMoves = append(ecb.completedMoves, m)
|
||||
case m.TargetNode != "" && volume_move.IsNoSpaceLeftError(err):
|
||||
if m.TargetDisk > 0 {
|
||||
// The refusal convicts only the named disk; the node's other
|
||||
// disks may still have room.
|
||||
if ecb.rejectedDisks == nil {
|
||||
ecb.rejectedDisks = make(map[string]map[uint32]bool)
|
||||
}
|
||||
if ecb.rejectedDisks[m.TargetNode] == nil {
|
||||
ecb.rejectedDisks[m.TargetNode] = make(map[uint32]bool)
|
||||
}
|
||||
ecb.rejectedDisks[m.TargetNode][m.TargetDisk] = true
|
||||
} else {
|
||||
// The server auto-picked and found no usable disk: the whole
|
||||
// node is out.
|
||||
if ecb.rejectedTargets == nil {
|
||||
ecb.rejectedTargets = make(map[string]bool)
|
||||
}
|
||||
ecb.rejectedTargets[m.TargetNode] = true
|
||||
}
|
||||
default:
|
||||
ecb.sawOtherErr = true
|
||||
}
|
||||
return ecb.balance(collections)
|
||||
}
|
||||
|
||||
// defaultECRatio resolves a collection's EC data/parity counts, defaulting to
|
||||
@@ -852,7 +1102,7 @@ func defaultECRatio(_ string) (int, int) {
|
||||
// balance plans EC shard moves with the shared planner and executes them. When
|
||||
// collections is empty all collections present are balanced.
|
||||
func (ecb *ecBalancer) balance(collections []string) error {
|
||||
topo, volumeRatio, selected := toBalancerTopology(ecb.ecNodes, collections, ecb.diskType, ecb.volumeIds, ecb.migratingVolumeIds)
|
||||
topo, volumeRatio, selected := toBalancerTopology(ecb.ecNodes, collections, ecb.diskType, ecb.volumeIds, ecb.migratingVolumeIds, ecb.noReceiveTargets, ecb.noReceiveDisks)
|
||||
if len(ecb.volumeIds) > 0 {
|
||||
requested := make([]uint32, 0, len(ecb.volumeIds))
|
||||
for vid := range ecb.volumeIds {
|
||||
@@ -896,6 +1146,7 @@ func (ecb *ecBalancer) balance(collections []string) error {
|
||||
}
|
||||
fmt.Printf("planned %d ec shard move(s) and %d ec shard deletion(s)\n", len(moves)-deletions, deletions)
|
||||
}
|
||||
ecb.plannedMoves = len(moves)
|
||||
return ecb.executeMoves(moves)
|
||||
}
|
||||
|
||||
@@ -907,7 +1158,7 @@ func (ecb *ecBalancer) balance(collections []string) error {
|
||||
// (0,0 when unreported, e.g. always in OSS), which Plan prefers over the
|
||||
// collection ratio for mixed-ratio clusters, and the set of volume ids that made
|
||||
// it into the topology.
|
||||
func toBalancerTopology(ecNodes []*EcNode, collections []string, diskType types.DiskType, volumeIds map[uint32]bool, migratingVolumeIds map[uint32]bool) (*ecbalancer.Topology, func(collection string, vid uint32) (int, int), map[uint32]bool) {
|
||||
func toBalancerTopology(ecNodes []*EcNode, collections []string, diskType types.DiskType, volumeIds map[uint32]bool, migratingVolumeIds map[uint32]bool, noReceiveTargets map[string]bool, noReceiveDisks map[string]map[uint32]bool) (*ecbalancer.Topology, func(collection string, vid uint32) (int, int), map[uint32]bool) {
|
||||
allowed := make(map[string]bool, len(collections))
|
||||
for _, c := range collections {
|
||||
allowed[c] = true
|
||||
@@ -924,11 +1175,17 @@ func toBalancerTopology(ecNodes []*EcNode, collections []string, diskType types.
|
||||
for _, en := range ecNodes {
|
||||
rackKey := string(en.DC) + ":" + string(en.Rack)
|
||||
node := topo.AddNode(en.Info.Id, string(en.DC), rackKey, en.FreeEcSlot)
|
||||
if noReceiveTargets[en.Info.Id] {
|
||||
node.SetNoReceive()
|
||||
}
|
||||
// Group by physical machine (host) so shards spread across machines, not just
|
||||
// nodes; the id stays the node identity used for moves.
|
||||
node.SetHost(pb.NewServerAddressFromDataNode(en.Info).ToHost())
|
||||
for diskId, d := range en.Disks {
|
||||
node.AddDisk(diskId, d.DiskType, d.FreeEcSlots, d.EcShardCount)
|
||||
if noReceiveDisks[en.Info.Id][diskId] {
|
||||
node.SetDiskNoReceive(diskId)
|
||||
}
|
||||
}
|
||||
for diskTypeKey, diskInfo := range en.Info.DiskInfos {
|
||||
if diskInfo == nil {
|
||||
@@ -1069,14 +1326,16 @@ func (ecb *ecBalancer) executeMove(byID map[string]*EcNode, m ecbalancer.Move) e
|
||||
// thing making this safe -- and the plan saying so is not evidence. A
|
||||
// topology entry can name a location holding nothing, and deleting on
|
||||
// that basis removes the last copy. Confirm the keep node has it.
|
||||
if err := verifyEcShardOnKeepNode(grpcDialOption, m.Collection, vid, m.KeepNode, shardId); err != nil {
|
||||
return err
|
||||
}
|
||||
err := verifyEcShardOnKeepNode(grpcDialOption, m.Collection, vid, m.KeepNode, shardId)
|
||||
addr := pb.NewServerAddressFromDataNode(src.Info)
|
||||
if err := UnmountEcShards(grpcDialOption, vid, addr, shardIds); err != nil {
|
||||
return err
|
||||
if err == nil {
|
||||
err = UnmountEcShards(grpcDialOption, vid, addr, shardIds)
|
||||
}
|
||||
return SourceServerDeleteEcShards(grpcDialOption, m.Collection, vid, addr, shardIds)
|
||||
if err == nil {
|
||||
err = SourceServerDeleteEcShards(grpcDialOption, m.Collection, vid, addr, shardIds)
|
||||
}
|
||||
ecb.recordMoveResult(m, err)
|
||||
return err
|
||||
}
|
||||
|
||||
dst := byID[m.TargetNode]
|
||||
@@ -1092,7 +1351,9 @@ func (ecb *ecBalancer) executeMove(byID map[string]*EcNode, m ecbalancer.Move) e
|
||||
// Dry-run: update the in-memory model only.
|
||||
return MoveMountedShardToEcNode(ecb.env, src, m.Collection, vid, shardId, dst, m.TargetDisk, false, ecb.diskType)
|
||||
}
|
||||
return ecb.applyShardMoveRPC(src, dst, m.Collection, vid, shardId, m.TargetDisk)
|
||||
err := ecb.applyShardMoveRPC(src, dst, m.Collection, vid, shardId, m.TargetDisk)
|
||||
ecb.recordMoveResult(m, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// applyShardMoveRPC copies a shard to the destination disk, verifies the
|
||||
|
||||
@@ -135,7 +135,10 @@ func (c *ecShardUnmounter) doShardsUnmount() error {
|
||||
for _, ts := range liveShards {
|
||||
if ts.ShardID == ps.ShardID {
|
||||
if ps.NodeAddress == "" || ps.NodeAddress == ts.NodeAddress {
|
||||
if result != nil {
|
||||
// One node is one unmount target no matter how many of
|
||||
// its disks hold the shard; ambiguity means the shard
|
||||
// lives on more than one node.
|
||||
if result != nil && result.NodeAddress != ts.NodeAddress {
|
||||
return fmt.Errorf("shard %v is ambiguous", ps)
|
||||
}
|
||||
result = ts
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -100,3 +102,82 @@ func TestEcShardStringRoundTrips(t *testing.T) {
|
||||
assert.Equal(t, []*ShardRef{s}, got)
|
||||
}
|
||||
}
|
||||
|
||||
func unmountTestTopology(nodes map[string]map[string][]uint32) *master_pb.TopologyInfo {
|
||||
var dataNodes []*master_pb.DataNodeInfo
|
||||
for addr, disks := range nodes {
|
||||
diskInfos := map[string]*master_pb.DiskInfo{}
|
||||
for disk, shardIds := range disks {
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
for _, sid := range shardIds {
|
||||
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(sid), 1))
|
||||
}
|
||||
diskInfos[disk] = &master_pb.DiskInfo{
|
||||
Type: "hdd",
|
||||
MaxVolumeCount: 100,
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
|
||||
Id: 100,
|
||||
Collection: "c1",
|
||||
EcIndexBits: si.Bitmap(),
|
||||
ShardSizes: si.SizesInt64(),
|
||||
DiskType: "hdd",
|
||||
}},
|
||||
}
|
||||
}
|
||||
dataNodes = append(dataNodes, &master_pb.DataNodeInfo{
|
||||
Id: addr,
|
||||
Address: addr,
|
||||
DiskInfos: diskInfos,
|
||||
})
|
||||
}
|
||||
return &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{Id: "r1", DataNodeInfos: dataNodes}},
|
||||
}}}
|
||||
}
|
||||
|
||||
// A shard held on two disks of one node is a single unmount target: the
|
||||
// unmount RPC addresses the node, not the disk.
|
||||
func TestUnmountResolvesShardDuplicatedOnOneNode(t *testing.T) {
|
||||
topo := unmountTestTopology(map[string]map[string][]uint32{
|
||||
"vs3:8080": {"hdd:0": {12}, "hdd:1": {12}},
|
||||
"vs4:8080": {"hdd:0": {13}},
|
||||
})
|
||||
|
||||
for _, shards := range [][]*ShardRef{
|
||||
{{ShardID: 12}},
|
||||
{{ShardID: 12, NodeAddress: "vs3:8080"}},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
err := UnmountShards(&Env{}, &out, ShardUnmountRequest{
|
||||
Topology: topo,
|
||||
VolumeID: 100,
|
||||
Shards: shards,
|
||||
})
|
||||
assert.NoError(t, err, "shard %+v", shards)
|
||||
assert.Contains(t, out.String(), "Will unmount 1 shard(s)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmountStillRejectsShardOnTwoNodes(t *testing.T) {
|
||||
topo := unmountTestTopology(map[string]map[string][]uint32{
|
||||
"vs3:8080": {"hdd:0": {12}},
|
||||
"vs4:8080": {"hdd:0": {12}},
|
||||
})
|
||||
|
||||
var out bytes.Buffer
|
||||
err := UnmountShards(&Env{}, &out, ShardUnmountRequest{
|
||||
Topology: topo,
|
||||
VolumeID: 100,
|
||||
Shards: []*ShardRef{{ShardID: 12}},
|
||||
})
|
||||
assert.ErrorContains(t, err, "ambiguous")
|
||||
|
||||
out.Reset()
|
||||
err = UnmountShards(&Env{}, &out, ShardUnmountRequest{
|
||||
Topology: topo,
|
||||
VolumeID: 100,
|
||||
Shards: []*ShardRef{{ShardID: 12, NodeAddress: "vs3:8080"}},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -390,6 +390,14 @@ func (store *AbstractSqlStore) DeleteFolderChildren(ctx context.Context, fullpat
|
||||
return doDeleteFolderChildren()
|
||||
}
|
||||
|
||||
var likeEscaper = strings.NewReplacer("!", "!!", "%", "!%", "_", "!_")
|
||||
|
||||
// LikePattern escapes LIKE wildcards so the prefix matches literally;
|
||||
// '!' pairs with the ESCAPE '!' clause emitted by the SqlGenerators.
|
||||
func LikePattern(prefix string) string {
|
||||
return likeEscaper.Replace(prefix) + "%"
|
||||
}
|
||||
|
||||
func (store *AbstractSqlStore) ListDirectoryPrefixedEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc filer.ListEachEntryFunc) (lastFileName string, err error) {
|
||||
|
||||
db, bucket, shortPath, err := store.getTxOrDB(ctx, dirPath, true)
|
||||
@@ -402,7 +410,7 @@ func (store *AbstractSqlStore) ListDirectoryPrefixedEntries(ctx context.Context,
|
||||
sqlText = store.GetSqlListInclusive(bucket)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(ctx, sqlText, util.HashStringToLong(string(shortPath)), startFileName, string(shortPath), prefix+"%", limit+1)
|
||||
rows, err := db.QueryContext(ctx, sqlText, util.HashStringToLong(string(shortPath)), startFileName, string(shortPath), LikePattern(prefix), limit+1)
|
||||
if err != nil {
|
||||
return lastFileName, fmt.Errorf("list %s : %v", dirPath, err)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,24 @@ func TestGetTxOrDBRealBucket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLikePattern(t *testing.T) {
|
||||
cases := []struct {
|
||||
prefix, want string
|
||||
}{
|
||||
{"", "%"},
|
||||
{"plain", "plain%"},
|
||||
{"call_03_KNl", "call!_03!_KNl%"},
|
||||
{"100%", "100!%%"},
|
||||
{"a!b", "a!!b%"},
|
||||
{`a\b`, `a\b%`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LikePattern(c.prefix); got != c.want {
|
||||
t.Errorf("LikePattern(%q) = %q, want %q", c.prefix, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPoolForKv(t *testing.T) {
|
||||
cases := []struct {
|
||||
maxOpen, mainOpen, kvOpen int
|
||||
|
||||
@@ -458,13 +458,13 @@ func retriedStreamFetchChunkData(ctx context.Context, writer io.Writer, urlStrin
|
||||
localProcessed += writtenCount
|
||||
totalWritten += writtenCount
|
||||
})
|
||||
if !shouldRetry {
|
||||
break
|
||||
}
|
||||
if writeErr != nil {
|
||||
err = writeErr
|
||||
break
|
||||
}
|
||||
if !shouldRetry {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
failed = true
|
||||
glog.V(0).InfofCtx(ctx, "read %s failed, err: %v", urlString, err)
|
||||
|
||||
@@ -787,3 +787,23 @@ func TestFetchWholeChunkUsesProvidedFilerJwt(t *testing.T) {
|
||||
return ""
|
||||
}))
|
||||
}
|
||||
|
||||
// A completed fetch whose destination write failed is a failed copy, not a
|
||||
// short success: the caller must see the write error, or a temp disk filling
|
||||
// mid-copy would still upload the truncated file.
|
||||
func TestRetriedStreamFetchChunkDataReportsWriteError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("0123456789"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
written, err := retriedStreamFetchChunkData(context.Background(), &failOnWrite{}, []string{srv.URL + "/5,abc"}, "", nil, false, true, 0, 0, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Zero(t, written)
|
||||
}
|
||||
|
||||
type failOnWrite struct{}
|
||||
|
||||
func (w *failOnWrite) Write(p []byte) (int, error) {
|
||||
return 0, fmt.Errorf("no space left")
|
||||
}
|
||||
|
||||
+19
-1
@@ -82,6 +82,11 @@ type Filer struct {
|
||||
persistedLogCache *persistedLogCache
|
||||
metaLogInflight metaLogInflight
|
||||
remoteTombstones *remoteDeletionTombstones
|
||||
// EntryLockTable serializes mutations per entry path. The filer server
|
||||
// holds it across local read-modify-write mutations and the meta
|
||||
// aggregator across a replayed peer change's check-and-apply, so the two
|
||||
// cannot interleave on the same path.
|
||||
EntryLockTable *util.LockTable[util.FullPath]
|
||||
// remoteTombstonesDone, when non-nil, is closed once the startup tombstone
|
||||
// rebuild finishes; lazy remote reads wait on it so a pending delete
|
||||
// cannot resurrect in the gap.
|
||||
@@ -110,6 +115,12 @@ type Filer struct {
|
||||
// overwriting the unread ledger with a partial set.
|
||||
deletionLedgerBlocked atomic.Bool
|
||||
deletionLedgerFlush chan struct{}
|
||||
// metaTsPending holds per-path commit records whose KV write failed,
|
||||
// keyed by path; ordering reads consult it until the retry lands them
|
||||
// (see meta_path_ts.go). metaTsRepairing marks paths with a live
|
||||
// repair goroutine so failures neither strand a record nor pile on.
|
||||
metaTsPending sync.Map
|
||||
metaTsRepairing sync.Map
|
||||
// flushCtx is the context metadata-log flushes append under. It stays
|
||||
// live for the process lifetime and is cancelled once during Shutdown,
|
||||
// bounding every retry - in-flight and queued alike - to one deadline.
|
||||
@@ -133,6 +144,7 @@ func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerH
|
||||
DeletionRetryQueue: NewDeletionRetryQueue(),
|
||||
persistedLogCache: newPersistedLogCache(persistedLogCacheMaxBytes),
|
||||
remoteTombstones: newRemoteDeletionTombstones(),
|
||||
EntryLockTable: util.NewLockTable[util.FullPath](),
|
||||
}
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
if f.UniqueFilerId < 0 {
|
||||
@@ -878,7 +890,7 @@ func (f *Filer) DeleteEmptyParentDirectories(ctx context.Context, dirPath util.F
|
||||
glog.V(2).InfofCtx(ctx, "DeleteEmptyParentDirectories: deleting empty directory %s", dirPath)
|
||||
parentDir, _ := dirPath.DirAndName()
|
||||
if dirEntry, findErr := f.FindEntry(ctx, dirPath); findErr == nil {
|
||||
if delErr := f.doDeleteEntryMetaAndData(ctx, dirEntry, false, false, nil); delErr == nil {
|
||||
if delErr := f.doDeleteEntryMetaAndData(f.emptyFolderDeleteContext(ctx, dirPath), dirEntry, false, false, nil); delErr == nil {
|
||||
// Successfully deleted, continue checking upwards
|
||||
f.DeleteEmptyParentDirectories(ctx, util.FullPath(parentDir), stopAtPath)
|
||||
} else {
|
||||
@@ -913,6 +925,12 @@ func (f *Filer) Shutdown() {
|
||||
f.LocalMetaLogBuffer.ShutdownLogBuffer()
|
||||
// The final metadata-log flush still needs the store to append its entry.
|
||||
f.LocalMetaLogBuffer.WaitForShutdown()
|
||||
// Stop peer subscriptions and let each persist its last applied offset;
|
||||
// the replay on the next start otherwise resumes from a stale offset and
|
||||
// applies those old events over newer local state.
|
||||
if f.MetaAggregator != nil {
|
||||
f.MetaAggregator.Shutdown()
|
||||
}
|
||||
// Persist the deletion ledger one last time before the store closes, so a
|
||||
// clean shutdown leaves the recovery set exactly consistent with reality.
|
||||
f.snapshotDeletionLedger()
|
||||
|
||||
@@ -80,6 +80,27 @@ func keepsRemoteObject(ctx context.Context) bool {
|
||||
return ctx.Value(keepRemoteObjectKey{}) != nil
|
||||
}
|
||||
|
||||
// emptyFolderDeleteContext makes deleting a folder that is empty in the filer
|
||||
// metadata-only when it is under a remote mount. A mount is populated lazily,
|
||||
// so the folder can still hold remote objects never read, and removing its
|
||||
// remote directory deletes every object under that prefix.
|
||||
func (f *Filer) emptyFolderDeleteContext(ctx context.Context, dirPath util.FullPath) context.Context {
|
||||
if f.IsUnderRemoteMount(dirPath) {
|
||||
return WithKeepRemoteObject(ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// IsUnderRemoteMount reports whether p is a remote mount directory or lies under one.
|
||||
func (f *Filer) IsUnderRemoteMount(p util.FullPath) bool {
|
||||
if f.RemoteStorage == nil {
|
||||
return false
|
||||
}
|
||||
// Mount rules are keyed with a trailing slash, so this also matches the mount directory itself.
|
||||
_, remoteLoc := f.RemoteStorage.FindMountDirectory(util.FullPath(string(p) + "/"))
|
||||
return remoteLoc != nil
|
||||
}
|
||||
|
||||
func deleteEventEntry(ctx context.Context, entry *Entry) *Entry {
|
||||
if !keepsRemoteObject(ctx) {
|
||||
return entry
|
||||
@@ -108,6 +129,9 @@ func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isR
|
||||
if ifNotModifiedAfter > 0 && entry.Attr.Mtime.Unix() > ifNotModifiedAfter {
|
||||
return nil
|
||||
}
|
||||
if !isRecursive && entry.IsDirectory() {
|
||||
ctx = f.emptyFolderDeleteContext(ctx, p)
|
||||
}
|
||||
isDeleteCollection := f.IsBucket(entry)
|
||||
collectionName := ""
|
||||
if isDeleteCollection {
|
||||
@@ -170,7 +194,7 @@ func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isR
|
||||
func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isDeletingBucket, isFromOtherCluster bool, signatures []int32, onHardLinkIdsFn OnHardLinkIdsFunc) (err error) {
|
||||
|
||||
var dirTombstoneTs int64
|
||||
if isRecursive {
|
||||
if isRecursive && !keepsRemoteObject(ctx) {
|
||||
// Tombstone the directory before its children: when the store drops
|
||||
// the subtree without listing it, or a child error aborts the walk,
|
||||
// the ancestor tombstone still covers every descendant.
|
||||
@@ -201,7 +225,9 @@ func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry
|
||||
|
||||
for _, sub := range entries {
|
||||
lastFileName = sub.Name()
|
||||
f.noteRemoteDeletion(sub.FullPath, sub.IsDirectory(), time.Now().UnixNano())
|
||||
if !keepsRemoteObject(ctx) {
|
||||
f.noteRemoteDeletion(sub.FullPath, sub.IsDirectory(), time.Now().UnixNano())
|
||||
}
|
||||
if sub.IsDirectory() {
|
||||
subIsDeletingBucket := f.IsBucket(sub)
|
||||
err = f.doBatchDeleteFolderMetaAndData(ctx, sub, isRecursive, ignoreRecursiveError, shouldDeleteChunks, subIsDeletingBucket, isFromOtherCluster, nil, onHardLinkIdsFn)
|
||||
@@ -264,7 +290,10 @@ func (f *Filer) doDeleteEntryMetaAndData(ctx context.Context, entry *Entry, shou
|
||||
}
|
||||
}
|
||||
|
||||
f.noteRemoteDeletion(entry.FullPath, entry.IsDirectory(), time.Now().UnixNano())
|
||||
// A metadata-only delete owes the remote nothing, so lazy reads stay open.
|
||||
if !keepsRemoteObject(ctx) {
|
||||
f.noteRemoteDeletion(entry.FullPath, entry.IsDirectory(), time.Now().UnixNano())
|
||||
}
|
||||
|
||||
if storeDeletionErr := f.Store.DeleteOneEntry(ctx, entry); storeDeletionErr != nil {
|
||||
return fmt.Errorf("filer store delete: %w", storeDeletionErr)
|
||||
|
||||
@@ -18,11 +18,15 @@ import (
|
||||
|
||||
type lazyFetchContextKey struct{}
|
||||
|
||||
// lazyParentsContextKey marks a FindEntry issued while materializing missing
|
||||
// parents: that fetch's own parent walk would only re-stat the same gaps.
|
||||
type lazyParentsContextKey struct{}
|
||||
|
||||
// maybeLazyFetchFromRemote is called by FindEntry when the store returns no
|
||||
// entry for p. If p is under a remote-storage mount, it stats the remote
|
||||
// object, builds a filer Entry from the result, and persists it via
|
||||
// CreateEntry with SkipCheckParentDirectory so phantom parent directories
|
||||
// under the mount are not required.
|
||||
// CreateEntry together with its missing parent directories: remote.uncache
|
||||
// and cache eviction find entries by walking down from the mount directory.
|
||||
//
|
||||
// On a CreateEntry failure after a successful StatFile the in-memory entry is
|
||||
// still returned (availability over consistency); the singleflight key is
|
||||
@@ -127,7 +131,10 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
|
||||
glog.V(2).InfofCtx(ctx, "maybeLazyFetchFromRemote: %s deleted during remote stat", p)
|
||||
return lazyFetchResult{nil}, nil
|
||||
}
|
||||
saveErr := f.CreateEntry(persistCtx, entry, nil, false, false, nil, true, f.MaxFilenameLength)
|
||||
if ctx.Value(lazyParentsContextKey{}) == nil {
|
||||
f.fetchMissingRemoteParents(persistBaseCtx, mountDir, p)
|
||||
}
|
||||
saveErr := f.CreateEntry(persistCtx, entry, nil, false, false, nil, false, f.MaxFilenameLength)
|
||||
if saveErr != nil {
|
||||
glog.Warningf("maybeLazyFetchFromRemote: failed to persist filer entry for %s: %v", p, saveErr)
|
||||
f.lazyFetchGroup.Forget(key)
|
||||
@@ -157,6 +164,25 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
|
||||
return result.entry, nil
|
||||
}
|
||||
|
||||
// fetchMissingRemoteParents looks up p's missing parents under mountDir, top
|
||||
// down, through lazy fetch. A remote object stored at a parent's key is then
|
||||
// materialized first, so creating p promotes it to a prefix object instead of
|
||||
// hiding it behind a plain directory. Each missing parent costs one remote
|
||||
// stat, once per directory.
|
||||
func (f *Filer) fetchMissingRemoteParents(ctx context.Context, mountDir, p util.FullPath) {
|
||||
var missing []util.FullPath
|
||||
for dir, _ := p.DirAndName(); len(dir) > len(mountDir); dir, _ = util.FullPath(dir).DirAndName() {
|
||||
if _, err := f.Store.FindEntry(ctx, util.FullPath(dir)); !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
break
|
||||
}
|
||||
missing = append(missing, util.FullPath(dir))
|
||||
}
|
||||
parentCtx := context.WithValue(ctx, lazyParentsContextKey{}, true)
|
||||
for i := len(missing) - 1; i >= 0; i-- {
|
||||
f.FindEntry(parentCtx, missing[i])
|
||||
}
|
||||
}
|
||||
|
||||
// retractLazyRemoteEntry deletes the entry at entry.FullPath only when it is
|
||||
// still the entry a lazy remote read just materialized — a concurrent write
|
||||
// may have replaced it, and deleting by path alone would take that write down.
|
||||
@@ -225,3 +251,97 @@ func (f *Filer) maybeDeleteFromRemote(ctx context.Context, entry *Entry) (bool,
|
||||
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted %s from remote", entry.FullPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteFromRemote deletes the object, or for a directory every object, the
|
||||
// entry maps to on its mounted remote storage.
|
||||
func (f *Filer) DeleteFromRemote(ctx context.Context, entry *Entry) error {
|
||||
_, err := f.maybeDeleteFromRemote(ctx, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtRemoteRenameSourceKey marks a remote-only entry whose rename committed
|
||||
// while its copy to the new key was still pending: it holds the path whose
|
||||
// object remote-only reads must serve until the copy stamps the entry.
|
||||
const ExtRemoteRenameSourceKey = "Seaweed-X-Remote-Rename-Source"
|
||||
|
||||
// RemoteSourcePath returns the path whose remote object holds the content of
|
||||
// the remote-only entry at p: the renamed-from path a pending deferred copy
|
||||
// still reads, or p itself. A marker leading out of p's mount is ignored, so
|
||||
// whoever can set extended attributes cannot point reads at another mount.
|
||||
func (f *Filer) RemoteSourcePath(entry *Entry, p util.FullPath) util.FullPath {
|
||||
src := util.FullPath(entry.Extended[ExtRemoteRenameSourceKey])
|
||||
if src == "" || f.RemoteStorage == nil {
|
||||
return p
|
||||
}
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(p)
|
||||
if remoteLoc == nil {
|
||||
return p
|
||||
}
|
||||
if srcMountDir, _ := f.RemoteStorage.FindMountDirectory(src); srcMountDir != mountDir {
|
||||
return p
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// RemoteReadPath returns the dir and name whose object a remote-only read of
|
||||
// the entry at dir/name serves.
|
||||
func (f *Filer) RemoteReadPath(entry *Entry, dir, name string) (string, string) {
|
||||
return f.RemoteSourcePath(entry, util.NewFullPath(dir, name)).DirAndName()
|
||||
}
|
||||
|
||||
// CopyRemoteOnlyEntry copies the remote object of a renamed entry whose
|
||||
// content exists only on the remote to the key of newPath, and returns the
|
||||
// RemoteEntry of the copy. The rename deletes the old key along with the old
|
||||
// entry, and with no local data nothing would write the new key. It returns
|
||||
// nil when no copy is needed: the entry has local data, which the remote sync
|
||||
// uploads to the new key, it is empty, which the sync writes as an empty
|
||||
// object, or it is not under a remote mount. A rename that
|
||||
// moves such an entry out of its mount, or onto a remote that cannot copy, is
|
||||
// refused, since it would delete the only copy of the content.
|
||||
func (f *Filer) CopyRemoteOnlyEntry(ctx context.Context, entry *Entry, oldPath, newPath util.FullPath) (*filer_pb.RemoteEntry, error) {
|
||||
src, dst, copier, err := f.RemoteCopyLocations(ctx, entry, oldPath, newPath)
|
||||
if err != nil || copier == nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteEntry, err := copier.CopyFile(src, dst)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy remote %s to %s: %w", remote_storage.FormatLocation(src), remote_storage.FormatLocation(dst), err)
|
||||
}
|
||||
glog.V(3).InfofCtx(ctx, "CopyRemoteOnlyEntry: copied %s to %s", remote_storage.FormatLocation(src), remote_storage.FormatLocation(dst))
|
||||
return remoteEntry, nil
|
||||
}
|
||||
|
||||
// RemoteCopyLocations resolves the object locations a remote-only rename copy
|
||||
// of entry between oldPath and newPath would use, and the client able to copy
|
||||
// it. A nil copier means the entry needs no copy; an error means it needs one
|
||||
// that cannot be served, which a rename must refuse before committing.
|
||||
func (f *Filer) RemoteCopyLocations(ctx context.Context, entry *Entry, oldPath, newPath util.FullPath) (src, dst *remote_pb.RemoteStorageLocation, copier remote_storage.RemoteStorageObjectCopier, err error) {
|
||||
if f.RemoteStorage == nil || entry.IsDirectory() || !entry.IsInRemoteOnly() || len(entry.Content) > 0 {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(oldPath)
|
||||
if remoteLoc == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
if newMountDir, _ := f.RemoteStorage.FindMountDirectory(newPath); newMountDir != mountDir {
|
||||
return nil, nil, nil, fmt.Errorf("%s exists only on the remote of mount %s and cannot be renamed out of it; cache it first", oldPath, mountDir)
|
||||
}
|
||||
|
||||
remoteConf, found := f.RemoteStorage.GetRemoteStorageConf(remoteLoc.Name)
|
||||
if !found {
|
||||
return nil, nil, nil, fmt.Errorf("resolve remote storage client for %s: not found", oldPath)
|
||||
}
|
||||
client, clientErr := f.buildRemoteStorageClient(ctx, remoteConf)
|
||||
if clientErr != nil {
|
||||
return nil, nil, nil, fmt.Errorf("resolve remote storage client for %s: %w", oldPath, clientErr)
|
||||
}
|
||||
copier, ok := client.(remote_storage.RemoteStorageObjectCopier)
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("%s exists only on the remote and remote storage type %s cannot copy it to the new name; cache it first", oldPath, remoteConf.Type)
|
||||
}
|
||||
|
||||
src = MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, f.RemoteSourcePath(entry, oldPath))
|
||||
dst = MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, newPath)
|
||||
return src, dst, copier, nil
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ func (f *Filer) maybeLazyListFromRemote(ctx context.Context, p util.FullPath) {
|
||||
persistCtx := context.WithValue(context.WithoutCancel(ctx), lazyListContextKey{}, true)
|
||||
persistCtx = context.WithValue(persistCtx, lazyFetchContextKey{}, true)
|
||||
|
||||
// Entries listed under p must stay reachable by a walk from the mount
|
||||
// directory, the same reason the lazy fetch fills in missing parents.
|
||||
f.fetchMissingRemoteParents(context.WithoutCancel(ctx), mountDir, p)
|
||||
|
||||
listErr := client.ListDirectory(persistCtx, objectLoc, func(dir string, name string, isDirectory bool, remoteEntry *filer_pb.RemoteEntry) error {
|
||||
childPath := p.Child(name)
|
||||
|
||||
@@ -215,7 +219,7 @@ func (f *Filer) updateDirectoryListingSyncedAt(ctx context.Context, p util.FullP
|
||||
dirEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
dirEntry.Extended[xattrRemoteListingSyncedAt] = []byte(fmt.Sprintf("%d", syncTime.Unix()))
|
||||
if saveErr := f.CreateEntry(ctx, dirEntry, nil, false, false, nil, true, f.MaxFilenameLength); saveErr != nil {
|
||||
if saveErr := f.CreateEntry(ctx, dirEntry, nil, false, false, nil, false, f.MaxFilenameLength); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: create dir synced_at for %s: %v", p, saveErr)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
@@ -35,6 +36,8 @@ type stubFilerStore struct {
|
||||
kv map[string][]byte
|
||||
insertErr error
|
||||
findErr error
|
||||
findErrByPath map[string]error
|
||||
kvPutErr error
|
||||
kvGetErr error
|
||||
kvDeleteErr error
|
||||
kvGetHook func(key []byte)
|
||||
@@ -49,6 +52,12 @@ func newStubFilerStore() *stubFilerStore {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) setKvPutErr(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.kvPutErr = err
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) GetName() string { return "stub" }
|
||||
func (s *stubFilerStore) Initialize(util.Configuration, string) error { return nil }
|
||||
func (s *stubFilerStore) Shutdown() {}
|
||||
@@ -60,6 +69,9 @@ 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()
|
||||
if s.kvPutErr != nil {
|
||||
return s.kvPutErr
|
||||
}
|
||||
s.kv[string(key)] = append([]byte(nil), value...)
|
||||
return nil
|
||||
}
|
||||
@@ -190,6 +202,9 @@ func (s *stubFilerStore) FindEntry(_ context.Context, p util.FullPath) (*Entry,
|
||||
if s.findErr != nil {
|
||||
return nil, s.findErr
|
||||
}
|
||||
if err := s.findErrByPath[string(p)]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e, ok := s.entries[string(p)]; ok {
|
||||
return e, nil
|
||||
}
|
||||
@@ -330,6 +345,11 @@ func putConfigEntry(store *stubFilerStore, path string, content []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// putDirEntry stores a plain directory entry, so a lazy fetch below it finds its parent in place.
|
||||
func putDirEntry(store *stubFilerStore, path string) {
|
||||
store.entries[path] = &Entry{FullPath: util.FullPath(path), Attr: Attr{Mode: os.ModeDir | 0755}}
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestMaybeLazyFetchFromRemote_HitsRemoteAndPersists(t *testing.T) {
|
||||
@@ -365,6 +385,199 @@ func TestMaybeLazyFetchFromRemote_HitsRemoteAndPersists(t *testing.T) {
|
||||
assert.Equal(t, []byte("zstd"), stored.Extended["Content-Encoding"])
|
||||
}
|
||||
|
||||
// pathStatRemoteClient stats only the objects it holds, keyed by remote path.
|
||||
type pathStatRemoteClient struct {
|
||||
stubRemoteClient
|
||||
objects map[string]*filer_pb.RemoteEntry
|
||||
statCalls []string
|
||||
}
|
||||
|
||||
func (c *pathStatRemoteClient) StatFile(loc *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
|
||||
c.statCalls = append(c.statCalls, loc.Path)
|
||||
if remoteEntry, found := c.objects[loc.Path]; found {
|
||||
return remoteEntry, nil
|
||||
}
|
||||
return nil, remote_storage.ErrRemoteObjectNotFound
|
||||
}
|
||||
|
||||
func newPathStatFiler(t *testing.T, storageType string, client remote_storage.RemoteStorageClient) (*Filer, *stubFilerStore) {
|
||||
t.Helper()
|
||||
t.Cleanup(registerStubMaker(t, storageType, client))
|
||||
conf := &remote_pb.RemoteConf{Name: "mystore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "mystore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
store := newStubFilerStore()
|
||||
return newTestFiler(t, store, rs), store
|
||||
}
|
||||
|
||||
func TestMaybeLazyFetchFromRemote_PersistsParentDirectories(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
|
||||
"/a/b/file.bin": {RemoteMtime: 1700000000, RemoteSize: 1234},
|
||||
"/a/b/other.bin": {RemoteMtime: 1700000000, RemoteSize: 5678},
|
||||
}}
|
||||
f, store := newPathStatFiler(t, "stub_lazy_parents", stub)
|
||||
|
||||
const filePath = "/buckets/mybucket/a/b/file.bin"
|
||||
entry, err := f.maybeLazyFetchFromRemote(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, entry)
|
||||
assert.Equal(t, []string{"/a/b/file.bin", "/a", "/a/b"}, stub.statCalls, "each missing parent is stat'ed once")
|
||||
|
||||
for _, dir := range []string{"/buckets/mybucket/a", "/buckets/mybucket/a/b"} {
|
||||
dirEntry := store.getEntry(dir)
|
||||
require.NotNil(t, dirEntry, "parent %s not persisted", dir)
|
||||
assert.True(t, dirEntry.IsDirectory(), "%s should be a directory", dir)
|
||||
assert.Nil(t, dirEntry.Remote, "%s holds no remote object", dir)
|
||||
}
|
||||
|
||||
// A sibling finds its parents in place and stats only itself.
|
||||
stub.statCalls = nil
|
||||
_, err = f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/a/b/other.bin")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/a/b/other.bin"}, stub.statCalls)
|
||||
|
||||
// Once the content is cached, eviction walks down from the mount and must reach it.
|
||||
stored := store.getEntry(filePath)
|
||||
require.NotNil(t, stored)
|
||||
stored.Chunks = []*filer_pb.FileChunk{{FileId: "1,01637037d6", Size: 1234}}
|
||||
stored.Remote.LastLocalSyncTsNs = time.Now().UnixNano()
|
||||
require.NoError(t, store.UpdateEntry(context.Background(), stored))
|
||||
|
||||
evictable := f.ListEvictableRemoteEntries(context.Background(), []util.FullPath{"/buckets/mybucket"}, 0)
|
||||
require.Len(t, evictable, 1)
|
||||
assert.Equal(t, util.FullPath(filePath), evictable[0].FullPath)
|
||||
}
|
||||
|
||||
func TestMaybeLazyFetchFromRemote_ParentLookupStopsAtStoreError(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
|
||||
"/a/b/file.bin": {RemoteMtime: 1700000000, RemoteSize: 1234},
|
||||
}}
|
||||
f, store := newPathStatFiler(t, "stub_lazy_parent_store_error", stub)
|
||||
store.findErrByPath = map[string]error{"/buckets/mybucket/a/b": errors.New("store unavailable")}
|
||||
|
||||
_, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/a/b/file.bin")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/a/b/file.bin"}, stub.statCalls, "a parent the store cannot read is not treated as missing")
|
||||
}
|
||||
|
||||
func TestMaybeLazyFetchFromRemote_PromotesRemoteObjectAtParentKey(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
|
||||
"/reports": {RemoteMtime: 1700000000, RemoteSize: 10},
|
||||
"/reports/2026.csv": {RemoteMtime: 1700000000, RemoteSize: 20},
|
||||
}}
|
||||
f, store := newPathStatFiler(t, "stub_lazy_prefix_object", stub)
|
||||
|
||||
_, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/reports/2026.csv")
|
||||
require.NoError(t, err)
|
||||
|
||||
parent := store.getEntry("/buckets/mybucket/reports")
|
||||
require.NotNil(t, parent)
|
||||
assert.True(t, parent.IsDirectory())
|
||||
assert.Equal(t, []byte("true"), parent.Extended[s3_constants.SeaweedFSPrefixObject], "the remote object at the parent key must stay readable")
|
||||
require.NotNil(t, parent.Remote)
|
||||
assert.Equal(t, int64(10), parent.Remote.RemoteSize)
|
||||
}
|
||||
|
||||
func TestMaybeLazyFetchFromRemote_StatsEachParentOnce(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
|
||||
"/a/b/c/file.bin": {RemoteMtime: 1700000000, RemoteSize: 1234},
|
||||
}}
|
||||
f, _ := newPathStatFiler(t, "stub_lazy_parent_stats", stub)
|
||||
|
||||
_, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/a/b/c/file.bin")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/a/b/c/file.bin", "/a", "/a/b", "/a/b/c"}, stub.statCalls,
|
||||
"a parent still missing after its own stat must not be re-stat'ed by the next lookup")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_PersistsParentDirectories(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{}}
|
||||
stub.listDirFn = func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "file.bin", false, &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 1234})
|
||||
}
|
||||
f, store := newPathStatFiler(t, "stub_lazy_list_parents", stub)
|
||||
f.RemoteStorage.mapDirectoryToRemoteStorage("/buckets/listmount", &remote_pb.RemoteStorageLocation{
|
||||
Name: "mystore",
|
||||
Bucket: "listmount",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/listmount/a/b"))
|
||||
|
||||
assert.Equal(t, []string{"/a"}, stub.statCalls, "each missing parent is stat'ed once")
|
||||
for _, dir := range []string{"/buckets/listmount", "/buckets/listmount/a", "/buckets/listmount/a/b"} {
|
||||
dirEntry := store.getEntry(dir)
|
||||
require.NotNil(t, dirEntry, "%s not persisted", dir)
|
||||
assert.True(t, dirEntry.IsDirectory(), "%s should be a directory", dir)
|
||||
}
|
||||
|
||||
child := store.getEntry("/buckets/listmount/a/b/file.bin")
|
||||
require.NotNil(t, child)
|
||||
child.Chunks = []*filer_pb.FileChunk{{FileId: "1,01637037d6", Size: 1234}}
|
||||
child.Remote.LastLocalSyncTsNs = time.Now().UnixNano()
|
||||
require.NoError(t, store.UpdateEntry(context.Background(), child))
|
||||
|
||||
evictable := f.ListEvictableRemoteEntries(context.Background(), []util.FullPath{"/buckets/listmount"}, 0)
|
||||
require.Len(t, evictable, 1)
|
||||
assert.Equal(t, util.FullPath("/buckets/listmount/a/b/file.bin"), evictable[0].FullPath)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_EmptyFolderUnderMountKeepsRemotePrefix(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{objects: map[string]*filer_pb.RemoteEntry{
|
||||
"/dir/unread.bin": {RemoteMtime: 1700000000, RemoteSize: 1},
|
||||
}}
|
||||
f, store := newPathStatFiler(t, "stub_lazy_empty_folder", stub)
|
||||
dirPath := util.FullPath("/buckets/mybucket/dir")
|
||||
putDirEntry(store, string(dirPath))
|
||||
|
||||
require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), dirPath, false, false, false, false, nil, 0))
|
||||
|
||||
assert.Empty(t, stub.removeCalls, "an empty local folder must not remove its remote prefix")
|
||||
assert.Nil(t, store.getEntry(string(dirPath)))
|
||||
entry, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/dir/unread.bin")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, entry, "a metadata-only delete must not block lazy reads under the folder")
|
||||
}
|
||||
|
||||
func TestDeleteEmptyParentDirectories_UnderMountKeepsRemotePrefix(t *testing.T) {
|
||||
stub := &pathStatRemoteClient{}
|
||||
f, store := newPathStatFiler(t, "stub_lazy_expired_parents", stub)
|
||||
f.DirBucketsPath = "/buckets"
|
||||
putDirEntry(store, "/buckets/mybucket/a")
|
||||
putDirEntry(store, "/buckets/mybucket/a/b")
|
||||
|
||||
f.DeleteEmptyParentDirectories(context.Background(), "/buckets/mybucket/a/b", "/buckets")
|
||||
|
||||
assert.Empty(t, stub.removeCalls, "an empty local folder must not remove its remote prefix")
|
||||
assert.Nil(t, store.getEntry("/buckets/mybucket/a/b"))
|
||||
assert.Nil(t, store.getEntry("/buckets/mybucket/a"))
|
||||
}
|
||||
|
||||
func TestIsUnderRemoteMount(t *testing.T) {
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mirror", &remote_pb.RemoteStorageLocation{
|
||||
Name: "mystore",
|
||||
Bucket: "mirror",
|
||||
Path: "/",
|
||||
})
|
||||
f := newTestFiler(t, newStubFilerStore(), rs)
|
||||
|
||||
for path, want := range map[util.FullPath]bool{
|
||||
"/buckets/mirror": true,
|
||||
"/buckets/mirror/a/b": true,
|
||||
"/buckets/mirror2/a": false,
|
||||
"/buckets/local/a": false,
|
||||
} {
|
||||
assert.Equal(t, want, f.IsUnderRemoteMount(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeLazyFetchFromRemote_NotUnderMount(t *testing.T) {
|
||||
rs := NewFilerRemoteStorage()
|
||||
store := newStubFilerStore()
|
||||
|
||||
@@ -71,6 +71,10 @@ func (f *Filer) notifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry
|
||||
}
|
||||
|
||||
f.logMetaEvent(ctx, event)
|
||||
// Record the commit timestamp so a peer replay ordered by the entry lock
|
||||
// this mutation may still be under sees the local change, including as a
|
||||
// tombstone after the entry itself is gone.
|
||||
f.noteCommittedEvent(event)
|
||||
if sink := metadataEventSinkFromContext(ctx); sink != nil {
|
||||
sink.Record(event)
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ func (f *Filer) onRemoteDeletionEvents(event *filer_pb.SubscribeMetadataResponse
|
||||
if message == nil {
|
||||
return
|
||||
}
|
||||
if message.OldEntry != nil {
|
||||
if message.OldEntry != nil && !IsMetadataOnlyDelete(message.OldEntry) {
|
||||
sourcePath := filer_pb.MetadataEventSourceFullPath(event)
|
||||
if message.NewEntry == nil || sourcePath != filer_pb.MetadataEventTargetFullPath(event) {
|
||||
if f.RemoteStorage != nil && f.remoteTombstones != nil {
|
||||
|
||||
@@ -187,6 +187,7 @@ func TestMaybeLazyFetchFromRemote_SyncOffsetReleasesTombstone(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, store := newMountedTestFiler(t, storageType, stub, 0)
|
||||
putDirEntry(store, "/buckets/mybucket/dir")
|
||||
|
||||
filePath := util.FullPath("/buckets/mybucket/dir/a.txt")
|
||||
deleteTsNs := time.Now().UnixNano()
|
||||
@@ -222,6 +223,7 @@ func TestDeleteEntryMetaAndData_TombstonesPath(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, store := newMountedTestFiler(t, storageType, stub, 0)
|
||||
putDirEntry(store, "/buckets/mybucket/dir")
|
||||
|
||||
filePath := util.FullPath("/buckets/mybucket/dir/a.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
@@ -257,6 +259,35 @@ func TestDeleteEntryMetaAndData_TombstonesPath(t *testing.T) {
|
||||
assert.Equal(t, 1, stub.statCalls)
|
||||
}
|
||||
|
||||
func TestOnMetadataChangeEvent_MetadataOnlyDeleteSkipsTombstone(t *testing.T) {
|
||||
const storageType = "stub_tomb_metadata_only"
|
||||
stub := &countingRemoteClient{
|
||||
stubRemoteClient: stubRemoteClient{
|
||||
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 11},
|
||||
},
|
||||
}
|
||||
f, store := newMountedTestFiler(t, storageType, stub, 0)
|
||||
putDirEntry(store, "/buckets/mybucket/dir")
|
||||
|
||||
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/buckets/mybucket",
|
||||
TsNs: time.Now().UnixNano(),
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "dir",
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{ExtKeepRemoteObjectKey: []byte("true")},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// nothing is owed to the remote, so objects under the folder stay readable
|
||||
entry, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/dir/a.txt")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, entry)
|
||||
assert.Equal(t, 1, stub.statCalls)
|
||||
}
|
||||
|
||||
func TestOnMetadataChangeEvent_PeerDeleteTombstones(t *testing.T) {
|
||||
const storageType = "stub_tomb_peer"
|
||||
stub := &countingRemoteClient{
|
||||
|
||||
+196
-54
@@ -63,6 +63,11 @@ type MetaAggregator struct {
|
||||
flushAdvanced chan struct{}
|
||||
remotePeerArrived chan struct{}
|
||||
peerWatermarksLock sync.Mutex
|
||||
peerWg sync.WaitGroup
|
||||
// stopped is set under peerChansLock by Shutdown; OnPeerUpdate then
|
||||
// rejects adds so no subscription can outlive the shutdown wait and run
|
||||
// against a store Filer.Shutdown has already closed.
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// MetaAggregator only aggregates data "on the fly". The logs are not re-persisted to disk.
|
||||
@@ -87,6 +92,13 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
|
||||
ma.peerChansLock.Lock()
|
||||
defer ma.peerChansLock.Unlock()
|
||||
|
||||
// After Shutdown the master connection may still deliver an update; a
|
||||
// subscription started now would have no stop signal and could run past
|
||||
// the store close.
|
||||
if ma.stopped {
|
||||
return
|
||||
}
|
||||
|
||||
address := pb.ServerAddress(update.Address)
|
||||
if update.IsAdd {
|
||||
// the peer is already followed, restarting would only lose the events
|
||||
@@ -102,7 +114,11 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
|
||||
// Account for the peer before its stream signals; keep prior values
|
||||
// on reconnect.
|
||||
ma.initPeerWatermark(address)
|
||||
go ma.loopSubscribeToOneFiler(ma.filer, ma.self, address, startFrom, stopChan)
|
||||
ma.peerWg.Add(1)
|
||||
go func() {
|
||||
defer ma.peerWg.Done()
|
||||
ma.loopSubscribeToOneFiler(ma.filer, ma.self, address, startFrom, stopChan)
|
||||
}()
|
||||
} else {
|
||||
if prevChan, found := ma.peerChans[address]; found {
|
||||
close(prevChan)
|
||||
@@ -344,6 +360,42 @@ func (ma *MetaAggregator) HasPeer(address pb.ServerAddress) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Shutdown stops every peer subscription and waits for each to return,
|
||||
// letting the deferred offset save persist the last applied position before
|
||||
// the store closes. Without it a graceful restart resumes from the last
|
||||
// periodically-saved offset — up to a minute stale — and replays those
|
||||
// events over newer local state.
|
||||
func (ma *MetaAggregator) Shutdown() {
|
||||
ma.peerChansLock.Lock()
|
||||
ma.stopped = true
|
||||
chans := ma.peerChans
|
||||
ma.peerChans = make(map[pb.ServerAddress]chan struct{})
|
||||
for _, stopChan := range chans {
|
||||
close(stopChan)
|
||||
}
|
||||
ma.peerChansLock.Unlock()
|
||||
|
||||
// Wait without a cap: every blocking point in the subscribe loop is
|
||||
// stop-aware, so a peer still running here is genuinely wedged and
|
||||
// returning anyway would let Filer.Shutdown close the store under it and
|
||||
// skip its deferred offset save.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ma.peerWg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
glog.Warningf("meta aggregator shutdown: peer subscriptions still stopping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddress, peer pb.ServerAddress, startFrom time.Time, stopChan chan struct{}) {
|
||||
lastTsNs := startFrom.UnixNano()
|
||||
for {
|
||||
@@ -368,7 +420,12 @@ func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddres
|
||||
if lastTsNs < nextLastTsNs {
|
||||
lastTsNs = nextLastTsNs
|
||||
}
|
||||
time.Sleep(1733 * time.Millisecond)
|
||||
select {
|
||||
case <-stopChan:
|
||||
glog.V(0).Infof("stop subscribing peer %s meta change", peer)
|
||||
return
|
||||
case <-time.After(1733 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,11 +441,34 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
|
||||
*/
|
||||
|
||||
var maybeReplicateMetadataChange func(*filer_pb.SubscribeMetadataResponse)
|
||||
var maybeReplicateMetadataChange func(*filer_pb.SubscribeMetadataResponse) error
|
||||
lastPersistTime := time.Now()
|
||||
lastTsNs := startFrom
|
||||
// replayFloorTsNs is the earliest event this session gave up replaying.
|
||||
// The persisted offset and the reconnect resume point must stay behind
|
||||
// it so the event is redelivered on the next subscribe instead of being
|
||||
// skipped forever.
|
||||
var replayFloorTsNs int64
|
||||
persistableTsNs := func(tsNs int64) int64 {
|
||||
if replayFloorTsNs > 0 && replayFloorTsNs-1 < tsNs {
|
||||
return replayFloorTsNs - 1
|
||||
}
|
||||
return tsNs
|
||||
}
|
||||
|
||||
peerSignature, err := ma.readFilerStoreSignature(peer)
|
||||
// Everything below derives from ctx, cancelled when the peer is removed
|
||||
// or the aggregator shuts down, so no stage can block past its stop.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-stopChan:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
peerSignature, err := ma.readFilerStoreSignature(ctx, peer)
|
||||
if err != nil {
|
||||
return lastTsNs, fmt.Errorf("connecting to peer filer %s: %v", peer, err)
|
||||
}
|
||||
@@ -408,7 +488,7 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
// different concept and can be set to arbitrary values.
|
||||
preTraverseTime := time.Now()
|
||||
glog.V(0).Infof("no previous offset for peer %s, starting full metadata sync", peer)
|
||||
if traverseErr := ma.traversePeerMetadata(f, peer); traverseErr != nil {
|
||||
if traverseErr := ma.traversePeerMetadata(ctx, f, peer); traverseErr != nil {
|
||||
return lastTsNs, fmt.Errorf("initial metadata sync from %s: %v", peer, traverseErr)
|
||||
}
|
||||
lastTsNs = preTraverseTime.Add(-time.Minute).UnixNano()
|
||||
@@ -420,11 +500,12 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
return lastTsNs, fmt.Errorf("read offset for peer %s: %w", peer, err)
|
||||
}
|
||||
defer func(prevTsNs int64) {
|
||||
if lastTsNs != prevTsNs && lastTsNs != lastPersistTime.UnixNano() {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, lastTsNs); err == nil {
|
||||
glog.V(0).Infof("last sync time with %s at %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
|
||||
saveTsNs := persistableTsNs(lastTsNs)
|
||||
if saveTsNs != prevTsNs && lastTsNs != lastPersistTime.UnixNano() {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, saveTsNs); err == nil {
|
||||
glog.V(0).Infof("last sync time with %s at %v (%d)", peer, time.Unix(0, saveTsNs), saveTsNs)
|
||||
} else {
|
||||
glog.Errorf("failed to save last sync time with %s at %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
|
||||
glog.Errorf("failed to save last sync time with %s at %v (%d)", peer, time.Unix(0, saveTsNs), saveTsNs)
|
||||
}
|
||||
}
|
||||
}(lastTsNs)
|
||||
@@ -432,11 +513,23 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
glog.V(0).Infof("follow peer: %v, last %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
|
||||
var counter int64
|
||||
var synced bool
|
||||
maybeReplicateMetadataChange = func(event *filer_pb.SubscribeMetadataResponse) {
|
||||
replicateMetadataChange(f.Store, peer, event)
|
||||
maybeReplicateMetadataChange = func(event *filer_pb.SubscribeMetadataResponse) error {
|
||||
if err := replicateMetadataChange(f, peer, event, stopChan); err != nil {
|
||||
if errors.Is(err, errPeerSubscriptionStopped) {
|
||||
return err
|
||||
}
|
||||
// A failed event becomes a barrier the persisted offset and
|
||||
// the reconnect resume point cannot pass, while the stream
|
||||
// keeps flowing: blocking on an event that can never replay
|
||||
// would stall every later event from this peer.
|
||||
if replayFloorTsNs == 0 || event.TsNs < replayFloorTsNs {
|
||||
replayFloorTsNs = event.TsNs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
counter++
|
||||
if lastPersistTime.Add(time.Minute).Before(time.Now()) {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, event.TsNs); err == nil {
|
||||
if err := ma.updateOffset(f, peer, peerSignature, persistableTsNs(event.TsNs)); err == nil {
|
||||
if event.TsNs < time.Now().Add(-2*time.Minute).UnixNano() {
|
||||
glog.V(0).Infof("sync with %s progressed to: %v %0.2f/sec", peer, time.Unix(0, event.TsNs), float64(counter)/60.0)
|
||||
} else if !synced {
|
||||
@@ -449,6 +542,7 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
glog.V(0).Infof("failed to update offset for %v: %v", peer, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +559,9 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
return err
|
||||
}
|
||||
if maybeReplicateMetadataChange != nil {
|
||||
maybeReplicateMetadataChange(event)
|
||||
if err := maybeReplicateMetadataChange(event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -476,17 +572,8 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
|
||||
glog.V(0).Infof("subscribing remote %s meta change: %v, clientId:%d", peer, time.Unix(0, lastTsNs), ma.filer.UniqueFilerId)
|
||||
err = pb.WithFilerClient(true, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Stop the stream promptly on removal; the blocking Recv below would
|
||||
// otherwise only notice when the stream errors on its own.
|
||||
go func() {
|
||||
select {
|
||||
case <-stopChan:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
// ctx is cancelled on stopChan by the watch above; the blocking Recv
|
||||
// below stops promptly on removal or shutdown.
|
||||
atomic.AddInt32(&ma.filer.UniqueFilerEpoch, 1)
|
||||
// Construct a log file reader that reads chunks via the peer filer's LookupVolume.
|
||||
lookupFn := LookupFn(filerClient{client})
|
||||
@@ -511,6 +598,14 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
}
|
||||
|
||||
processOne := func(event *filer_pb.SubscribeMetadataResponse) error {
|
||||
// Bail promptly on stop; a cancelled stream may still deliver
|
||||
// queued events while unwinding, and replaying them now only
|
||||
// delays the offset save the shutdown is waiting on.
|
||||
select {
|
||||
case <-stopChan:
|
||||
return errPeerSubscriptionStopped
|
||||
default:
|
||||
}
|
||||
if err := processEventFn(event); err != nil {
|
||||
glog.V(0).Infof("SubscribeLocalMetadata process %v: %v", event, err)
|
||||
return fmt.Errorf("process %v: %w", event, err)
|
||||
@@ -587,19 +682,37 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
|
||||
}
|
||||
}
|
||||
})
|
||||
return lastTsNs, err
|
||||
return persistableTsNs(lastTsNs), err
|
||||
}
|
||||
|
||||
// replicateMetadataChange retries transient Replay failures with bounded
|
||||
// backoff. A failure that outlives the retry budget is counted and logged,
|
||||
// then skipped: blocking on an event that can never replay would stall every
|
||||
// later event from this peer, which is worse than one entry staying stale.
|
||||
func replicateMetadataChange(store FilerStore, peer pb.ServerAddress, event *filer_pb.SubscribeMetadataResponse) {
|
||||
err := util.Retry("replicate metadata change from "+string(peer), func() error {
|
||||
return Replay(store, event)
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
// backoff and reports the outcome. errPeerSubscriptionStopped is returned
|
||||
// when the subscription stops mid-retry so the caller aborts and the offset
|
||||
// save keeps the last completed event. A failure that outlives the retry
|
||||
// budget is counted and logged, then returned: the caller pins the peer's
|
||||
// offset at the event so a restart replays it rather than skipping it.
|
||||
func replicateMetadataChange(f *Filer, peer pb.ServerAddress, event *filer_pb.SubscribeMetadataResponse, stopChan <-chan struct{}) error {
|
||||
waitTime := time.Second
|
||||
var err error
|
||||
for waitTime < util.RetryWaitTime {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return errPeerSubscriptionStopped
|
||||
default:
|
||||
}
|
||||
if err = f.Replay(event); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !util.IsTransientError(err) {
|
||||
break
|
||||
}
|
||||
glog.V(0).Infof("retry replicate metadata change from %s: %v", peer, err)
|
||||
select {
|
||||
case <-stopChan:
|
||||
return errPeerSubscriptionStopped
|
||||
case <-time.After(waitTime):
|
||||
}
|
||||
waitTime += waitTime / 2
|
||||
}
|
||||
stats.FilerMetaAggregatorReplayFailures.WithLabelValues(string(peer)).Inc()
|
||||
name := event.GetEventNotification().GetNewEntry().GetName()
|
||||
@@ -607,15 +720,20 @@ func replicateMetadataChange(store FilerStore, peer pb.ServerAddress, event *fil
|
||||
name = event.GetEventNotification().GetOldEntry().GetName()
|
||||
}
|
||||
glog.Errorf("giving up replicating metadata change from %s for %s/%s (ts=%d): %v", peer, event.Directory, name, event.TsNs, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// errPeerSubscriptionStopped ends event processing once the subscription's
|
||||
// stop channel closed.
|
||||
var errPeerSubscriptionStopped = errors.New("peer subscription stopped")
|
||||
|
||||
// traversePeerMetadata does a full BFS traversal of a peer filer's metadata
|
||||
// and inserts all entries into the local store. This is used when a filer
|
||||
// connects to a peer for the first time and needs to bootstrap pre-existing data.
|
||||
func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress) error {
|
||||
// The ctx cancels on the peer's stop channel so a shutdown does not have to
|
||||
// out-wait a long traversal.
|
||||
func (ma *MetaAggregator) traversePeerMetadata(ctx context.Context, f *Filer, peer pb.ServerAddress) error {
|
||||
return pb.WithFilerClient(true, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
stream, err := client.TraverseBfsMetadata(ctx, &filer_pb.TraverseBfsMetadataRequest{
|
||||
Directory: "/",
|
||||
ExcludedPrefixes: []string{SystemLogDir},
|
||||
@@ -635,22 +753,17 @@ func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress)
|
||||
if resp.Entry == nil {
|
||||
continue
|
||||
}
|
||||
fullpath := util.Join(resp.Directory, resp.Entry.Name)
|
||||
entry := FromPbEntry(resp.Directory, resp.Entry)
|
||||
if insertErr := f.Store.InsertEntry(context.Background(), entry); insertErr != nil {
|
||||
// Entry may already exist (root dir, or partial previous bootstrap).
|
||||
existing, findErr := f.Store.FindEntry(context.Background(), entry.FullPath)
|
||||
if findErr != nil {
|
||||
return fmt.Errorf("insert entry %s: %w", fullpath, insertErr)
|
||||
}
|
||||
// Only overwrite if the peer's entry is newer.
|
||||
if entry.Attr.Mtime.After(existing.Attr.Mtime) {
|
||||
if updateErr := f.Store.UpdateEntry(context.Background(), entry); updateErr != nil {
|
||||
return fmt.Errorf("update entry %s: %w", fullpath, updateErr)
|
||||
}
|
||||
} else {
|
||||
glog.V(1).Infof("skip older peer entry %s (peer mtime %v <= local mtime %v)", fullpath, entry.Attr.Mtime, existing.Attr.Mtime)
|
||||
}
|
||||
// Order on the sender's per-path commit timestamp, not the
|
||||
// entry's mtime — mtime is client-settable and does not track
|
||||
// when the peer committed the change. Peers that predate the
|
||||
// field send 0 and fall back to mtime.
|
||||
peerTsNs := resp.EntryTsNs
|
||||
if peerTsNs == 0 {
|
||||
peerTsNs = entry.Attr.Mtime.UnixNano()
|
||||
}
|
||||
if applyErr := f.applyTraversedEntry(ctx, entry, peerTsNs); applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
count++
|
||||
if count%10000 == 0 {
|
||||
@@ -662,9 +775,38 @@ func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress)
|
||||
})
|
||||
}
|
||||
|
||||
func (ma *MetaAggregator) readFilerStoreSignature(peer pb.ServerAddress) (sig int32, err error) {
|
||||
// applyTraversedEntry merges one bootstrap entry under the path's replay
|
||||
// locks, keeping local state when its commit is newer than the sender's —
|
||||
// most importantly a delete, whose tombstone must not let the bootstrap
|
||||
// resurrect the path. An applied entry records the sender's commit time so a
|
||||
// later peer replay orders against it rather than the entry's mtime.
|
||||
func (f *Filer) applyTraversedEntry(ctx context.Context, entry *Entry, peerTsNs int64) error {
|
||||
unlock := f.lockReplayPaths(entry.FullPath, "")
|
||||
defer unlock()
|
||||
localTsNs, err := f.localCommitTsNs(ctx, entry.FullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check local state for %s: %w", entry.FullPath, err)
|
||||
}
|
||||
if localTsNs >= peerTsNs {
|
||||
return nil
|
||||
}
|
||||
if insertErr := f.Store.InsertEntry(ctx, entry); insertErr != nil {
|
||||
// Entry already exists (root dir, or partial previous bootstrap);
|
||||
// the lock kept the check above current, so the peer commit is newer.
|
||||
if _, findErr := f.Store.FindEntry(ctx, entry.FullPath); findErr != nil {
|
||||
return fmt.Errorf("insert entry %s: %w", entry.FullPath, insertErr)
|
||||
}
|
||||
if updateErr := f.Store.UpdateEntry(ctx, entry); updateErr != nil {
|
||||
return fmt.Errorf("update entry %s: %w", entry.FullPath, updateErr)
|
||||
}
|
||||
}
|
||||
f.notePathChange(entry.FullPath, peerTsNs, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ma *MetaAggregator) readFilerStoreSignature(ctx context.Context, peer pb.ServerAddress) (sig int32, err error) {
|
||||
err = pb.WithFilerClient(false, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
||||
resp, err := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package filer
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15,6 +16,14 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// newReplayTestFiler builds the minimum Filer Replay needs over a stub store.
|
||||
func newReplayTestFiler(store FilerStore) *Filer {
|
||||
return &Filer{
|
||||
Store: NewFilerStoreWrapper(store),
|
||||
EntryLockTable: util.NewLockTable[util.FullPath](),
|
||||
}
|
||||
}
|
||||
|
||||
// flakyReplayStore fails InsertEntry for its first failUntil calls, then
|
||||
// delegates to the embedded stubFilerStore.
|
||||
type flakyReplayStore struct {
|
||||
@@ -73,8 +82,11 @@ func TestReplicateMetadataChangeRetriesTransientFailure(t *testing.T) {
|
||||
err: errors.New("i/o timeout talking to store"),
|
||||
}
|
||||
peer := pb.ServerAddress("peer-transient:1")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
replicateMetadataChange(store, peer, quotaChangeEvent("my-bucket", wantQuota))
|
||||
if err := replicateMetadataChange(f, peer, quotaChangeEvent("my-bucket", wantQuota), nil); err != nil {
|
||||
t.Fatalf("replicateMetadataChange: %v", err)
|
||||
}
|
||||
|
||||
inserted, err := store.FindEntry(context.Background(), util.NewFullPath("/buckets", "my-bucket"))
|
||||
if err != nil {
|
||||
@@ -97,10 +109,14 @@ func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
|
||||
err: errors.New("entry checksum mismatch"),
|
||||
}
|
||||
peer := pb.ServerAddress("peer-permanent:1")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
before := replayFailureCount(t, peer)
|
||||
|
||||
replicateMetadataChange(store, peer, quotaChangeEvent("poison-bucket", 65536<<20))
|
||||
err := replicateMetadataChange(f, peer, quotaChangeEvent("poison-bucket", 65536<<20), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the exhausted replay error so the caller can pin the peer offset at the failed event")
|
||||
}
|
||||
|
||||
if got := store.attemptCount(); got != 1 {
|
||||
t.Fatalf("attempts = %d, want exactly 1: a non-transient error must fail fast, not retry", got)
|
||||
@@ -109,3 +125,756 @@ func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
|
||||
t.Fatalf("FilerMetaAggregatorReplayFailures[%s] = %v, want %v", peer, got, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func overwriteEvent(dir, name string, tsNs int64, content string) *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: dir,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: name},
|
||||
NewEntry: &filer_pb.Entry{Name: name, Content: []byte(content)},
|
||||
},
|
||||
TsNs: tsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func deleteEvent(dir, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: dir,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: name},
|
||||
},
|
||||
TsNs: tsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySkipsEventOlderThanLocalEntry(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now},
|
||||
Content: []byte("newer local write"),
|
||||
}
|
||||
|
||||
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "old replayed version")); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "newer local write" {
|
||||
t.Fatalf("content = %q, want local write kept", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAppliesEventNewerThanLocalEntry(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Minute)},
|
||||
Content: []byte("old local write"),
|
||||
}
|
||||
|
||||
if err := f.Replay(overwriteEvent("/dir", "file", now.UnixNano(), "newer peer write")); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "newer peer write" {
|
||||
t.Fatalf("content = %q, want peer write applied", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaySkipsDeleteOlderThanLocalEntry(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
|
||||
|
||||
if err := f.Replay(deleteEvent("/dir", "file", now.Add(-time.Minute).UnixNano())); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err != nil {
|
||||
t.Fatalf("newer local entry must survive a stale delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAppliesNewerDelete(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now.Add(-time.Minute)}}
|
||||
|
||||
if err := f.Replay(deleteEvent("/dir", "file", now.UnixNano())); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("newer delete must remove the local entry")
|
||||
}
|
||||
}
|
||||
|
||||
// A move event touches two paths; the local check applies to each side
|
||||
// independently, so a newer destination is kept while the source is removed.
|
||||
func TestReplayMoveChecksEachPathSeparately(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
oldPath := util.NewFullPath("/a", "file")
|
||||
newPath := util.NewFullPath("/b", "file")
|
||||
store.entries[string(oldPath)] = &Entry{FullPath: oldPath, Attr: Attr{Mtime: now.Add(-time.Minute)}}
|
||||
store.entries[string(newPath)] = &Entry{FullPath: newPath, Attr: Attr{Mtime: now}, Content: []byte("kept")}
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/a",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "file"},
|
||||
NewEntry: &filer_pb.Entry{Name: "file"},
|
||||
NewParentPath: "/b",
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), oldPath); err == nil {
|
||||
t.Fatal("old source entry should be deleted")
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "kept" {
|
||||
t.Fatalf("content = %q, want newer destination kept", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A local delete leaves a recorded tombstone, so a replayed create older than
|
||||
// the delete must not resurrect the path even though no entry remains to
|
||||
// compare against.
|
||||
func TestReplaySkipsCreateOlderThanLocalDelete(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
// The delete removed the entry; its commit record survives.
|
||||
f.notePathChange(path, now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(createEvent); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("stale replayed create resurrected a locally deleted entry")
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted directory covers its children: a bulk drop emits no per-child
|
||||
// events, so the tombstone on the ancestor is what a replayed child create
|
||||
// must check.
|
||||
func TestReplaySkipsCreateUnderDeletedAncestor(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
|
||||
f.notePathChange("/dir", now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "child", Content: []byte("stale")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(createEvent); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), "/dir/child"); err == nil {
|
||||
t.Fatal("stale replayed create resurrected a child of a deleted directory")
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded commit timestamp orders replay, not the entry's mtime: a rename
|
||||
// keeps the source's mtime on the destination, and a mounted client can set
|
||||
// mtime arbitrarily, so an entry whose mtime postdates the event must still
|
||||
// be overwritten when its commit record is older.
|
||||
func TestReplayAppliesWhenCommitRecordIsOlderThanFutureMtime(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/b", "x")
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set or kept from a rename source
|
||||
Content: []byte("old destination"),
|
||||
}
|
||||
// The last committed change predates the event.
|
||||
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/a",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "x"},
|
||||
NewEntry: &filer_pb.Entry{Name: "x", Content: []byte("renamed in")},
|
||||
NewParentPath: "/b",
|
||||
},
|
||||
TsNs: now.UnixNano(),
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if string(entry.Content) != "renamed in" {
|
||||
t.Fatalf("content = %q, want the rename destination applied", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A store read failure is not a not-found: the replayed change must not be
|
||||
// applied on the guess that no newer local entry exists.
|
||||
func TestReplayPropagatesLookupError(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
store.findErr = errors.New("store read failed")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
err := f.Replay(overwriteEvent("/dir", "file", time.Now().UnixNano(), "v"))
|
||||
if err == nil {
|
||||
t.Fatal("expected Replay to propagate the FindEntry failure")
|
||||
}
|
||||
}
|
||||
|
||||
// The check and the mutation share the per-path entry lock, so a local write
|
||||
// holding that lock cannot be split by a replayed change.
|
||||
func TestReplaySerializesWithEntryLock(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
|
||||
|
||||
// A local mutation holding the path lock; a replay started behind it
|
||||
// sees the recorded commit and skips.
|
||||
held := f.EntryLockTable.AcquireLock("test local write", path, util.ExclusiveLock)
|
||||
f.notePathChange(path, now.UnixNano(), false)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale"))
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("replay finished while the path lock was still held")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
f.EntryLockTable.ReleaseLock(path, held)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
entry, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("FindEntry: %v", err)
|
||||
}
|
||||
if entry.Content != nil {
|
||||
t.Fatalf("content = %q, want local entry kept", entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A closed stop channel must abort the retry loop with
|
||||
// errPeerSubscriptionStopped so the subscription tears down and the offset
|
||||
// save keeps the last completed event instead of skipping this one.
|
||||
func TestReplicateMetadataChangeAbortsOnStop(t *testing.T) {
|
||||
store := &flakyReplayStore{
|
||||
stubFilerStore: newStubFilerStore(),
|
||||
failUntil: 1 << 30,
|
||||
err: errors.New("i/o timeout talking to store"),
|
||||
}
|
||||
peer := pb.ServerAddress("peer-stopped:1")
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
close(stopChan)
|
||||
err := replicateMetadataChange(f, peer, quotaChangeEvent("stuck-bucket", 1<<20), stopChan)
|
||||
if !errors.Is(err, errPeerSubscriptionStopped) {
|
||||
t.Fatalf("err = %v, want errPeerSubscriptionStopped", err)
|
||||
}
|
||||
if got := store.attemptCount(); got != 0 {
|
||||
t.Fatalf("attempts = %d, want 0: a stopped subscription must not replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit-record keys must stay small: SQL KV stores keep the key suffix in a
|
||||
// VARCHAR(766) column, so a raw path over ~578 bytes silently drops the
|
||||
// record (the insert fails and the fallback update touches no row).
|
||||
func TestMetaPathKeyFixedSizeForLongPaths(t *testing.T) {
|
||||
path := util.NewFullPath("/", string(make([]rune, 600)))
|
||||
for _, p := range []util.FullPath{"/a", path} {
|
||||
if got := len(metaPathKey(metaPathTsPrefix, p)); got != len(metaPathTsPrefix)+16 {
|
||||
t.Fatalf("metaPathKey(%d-byte path) = %d bytes, want %d", len(p), got, len(metaPathTsPrefix)+16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact-path locking alone lets a replayed child create slip between a local
|
||||
// ancestor delete's tombstone check and the sweep: replay locks every
|
||||
// ancestor too, so it waits for the delete's lock and then sees the
|
||||
// tombstone.
|
||||
func TestReplaySerializesWithAncestorLock(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
|
||||
now := time.Now()
|
||||
child := util.NewFullPath("/dir", "file")
|
||||
held := f.EntryLockTable.AcquireLock("test local delete", "/dir", util.ExclusiveLock)
|
||||
f.notePathChange("/dir", now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- f.Replay(createEvent)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("replay finished while the ancestor lock was still held")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
f.EntryLockTable.ReleaseLock("/dir", held)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), child); err == nil {
|
||||
t.Fatal("stale replayed create resurrected a child of a locally deleted directory")
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap ordering uses the sender's commit timestamp: a local commit
|
||||
// newer than the traversed entry keeps its state even when the peer entry's
|
||||
// mtime is far in the future.
|
||||
func TestBootstrapSkipsPeerEntryBehindLocalDelete(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
f.notePathChange(path, now.UnixNano(), true)
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(time.Hour)}, // client-settable, must not win
|
||||
Content: []byte("stale peer entry"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.Add(-time.Minute).UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("bootstrap resurrected a path newer local delete covered")
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this guards: a peer entry committed recently but stamped with an
|
||||
// old client mtime must still apply over an absent-or-older local commit.
|
||||
func TestBootstrapAppliesPeerCommitNewerThanLocal(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Hour)}, // old client-set mtime
|
||||
Content: []byte("fresh peer write"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
got, err := store.FindEntry(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("peer entry with newer commit must bootstrap: %v", err)
|
||||
}
|
||||
if string(got.Content) != "fresh peer write" {
|
||||
t.Fatalf("content = %q, want peer entry applied", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A bootstrap-applied entry records the sender's commit timestamp, so a
|
||||
// replayed event older than it — but newer than the entry's mtime — is
|
||||
// still skipped.
|
||||
func TestBootstrapAppliedCommitBlocksStaleReplay(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Hour)},
|
||||
Content: []byte("fresh peer write"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale replay")); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
got, _ := store.FindEntry(context.Background(), path)
|
||||
if string(got.Content) != "fresh peer write" {
|
||||
t.Fatalf("content = %q, stale replay must not overwrite the bootstrap entry", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// An existing local entry is replaced only when the peer commit is newer —
|
||||
// not when its mtime happens to be.
|
||||
func TestBootstrapUpdatesExistingByCommitTime(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
store.entries[string(path)] = &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set
|
||||
Content: []byte("old local write"),
|
||||
}
|
||||
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
|
||||
|
||||
entry := &Entry{
|
||||
FullPath: path,
|
||||
Attr: Attr{Mtime: now.Add(-time.Hour)},
|
||||
Content: []byte("newer peer commit"),
|
||||
}
|
||||
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
|
||||
t.Fatalf("applyTraversedEntry: %v", err)
|
||||
}
|
||||
got, _ := store.FindEntry(context.Background(), path)
|
||||
if string(got.Content) != "newer peer commit" {
|
||||
t.Fatalf("content = %q, newer peer commit must update despite the future local mtime", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A delete whose commit record failed to persist still blocks a stale
|
||||
// replayed create: the pending record stands in until the retry lands it.
|
||||
func TestPendingDeleteRecordBlocksStaleReplay(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
now := time.Now()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
// The delete already committed; only its ordering record failed.
|
||||
f.notePathChange(path, now.UnixNano(), true)
|
||||
|
||||
createEvent := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
|
||||
},
|
||||
TsNs: now.Add(-time.Minute).UnixNano(),
|
||||
}
|
||||
if err := f.Replay(createEvent); err != nil {
|
||||
t.Fatalf("Replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(context.Background(), path); err == nil {
|
||||
t.Fatal("stale create resurrected a path whose delete record is still pending")
|
||||
}
|
||||
}
|
||||
|
||||
// The failed record write retries until it lands, then leaves the pending set.
|
||||
func TestPendingCommitRecordRepairs(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f.notePathChange(path, 100, true)
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
t.Fatal("failed record write must stay pending")
|
||||
}
|
||||
store.setKvPutErr(nil)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path))
|
||||
if err != nil || !found || ts != 100 {
|
||||
t.Fatalf("delete record after repair: ts=%v found=%v err=%v", ts, found, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("pending record was not repaired")
|
||||
}
|
||||
|
||||
// CommitTsNs reports the recorded commit time over the entry mtime, and a
|
||||
// still-pending record over both.
|
||||
func TestCommitTsNsPrefersRecordAndPending(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 7 {
|
||||
t.Fatalf("no record: CommitTsNs = %d, %v; want fallback 7", got, err)
|
||||
}
|
||||
f.notePathChange(path, 50, false)
|
||||
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 50 {
|
||||
t.Fatalf("recorded: CommitTsNs = %d, %v; want 50", got, err)
|
||||
}
|
||||
pending := &metaTsPendingRec{}
|
||||
pending.changeTsNs.Store(80)
|
||||
f.metaTsPending.Store(string(path), pending)
|
||||
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 80 {
|
||||
t.Fatalf("pending: CommitTsNs = %d, %v; want 80", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A successful write discharges what the pending record owed before it, so a
|
||||
// repair pass can never put an older timestamp back over it.
|
||||
func TestPendingCommitRecordDischargedByNewerWrite(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f.notePathChange(path, 100, false)
|
||||
store.setKvPutErr(nil)
|
||||
f.notePathChange(path, 200, false)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path))
|
||||
if err != nil || !found || ts != 200 {
|
||||
t.Fatalf("MpTs after repair = %v, %v, %v; want 200", ts, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed delete record keeps its own time even when a later failed change
|
||||
// merges into the pending record: the repair must not stamp the delete at
|
||||
// the newer change's time.
|
||||
func TestPendingDeleteRecordKeepsItsTime(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
store.setKvPutErr(errors.New("kv store unreachable"))
|
||||
f := newReplayTestFiler(store)
|
||||
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
|
||||
defer f.flushCancel()
|
||||
path := util.NewFullPath("/dir", "file")
|
||||
|
||||
f.notePathChange(path, 100, true)
|
||||
f.notePathChange(path, 200, false)
|
||||
store.setKvPutErr(nil)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path)); err != nil || !found || ts != 200 {
|
||||
t.Fatalf("MpTs = %v, %v, %v; want 200", ts, found, err)
|
||||
}
|
||||
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path)); err != nil || !found || ts != 100 {
|
||||
t.Fatalf("MpDl = %v, %v, %v; want 100", ts, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A replayed directory delete must remove the local subtree too: the origin
|
||||
// only emits the directory's own key, and children the peer holds that the
|
||||
// origin never listed would orphan under the missing parent.
|
||||
func TestReplayDirectoryDeleteSweepsLocalChildren(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
ctx := context.Background()
|
||||
|
||||
put := func(path string, isDir bool) {
|
||||
e := &Entry{FullPath: util.FullPath(path)}
|
||||
if isDir {
|
||||
e.Attr.Mode = os.ModeDir
|
||||
}
|
||||
if err := store.InsertEntry(ctx, e); err != nil {
|
||||
t.Fatalf("insert %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
put("/d", true)
|
||||
put("/d/a.txt", false)
|
||||
put("/d/sub", true)
|
||||
put("/d/sub/nested.txt", false)
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
|
||||
DeleteChunks: false,
|
||||
},
|
||||
TsNs: time.Now().UnixNano(),
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/d", "/d/a.txt", "/d/sub", "/d/sub/nested.txt"} {
|
||||
if _, err := store.FindEntry(ctx, util.FullPath(path)); !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
t.Fatalf("expected %s gone after replayed directory delete, got %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayFileDeleteKeepsSiblingEntries(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
ctx := context.Background()
|
||||
|
||||
dir := &Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir}}
|
||||
if err := store.InsertEntry(ctx, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"gone.txt", "kept.txt"} {
|
||||
if err := store.InsertEntry(ctx, &Entry{FullPath: util.NewFullPath("/d", name)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/d",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "gone.txt"},
|
||||
},
|
||||
TsNs: time.Now().UnixNano(),
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(ctx, "/d/gone.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
t.Fatalf("expected deleted file gone, got %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(ctx, "/d/kept.txt"); err != nil {
|
||||
t.Fatalf("expected sibling kept, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayDirectoryDeleteKeepsNewerChildren(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
ctx := context.Background()
|
||||
|
||||
eventTs := time.Now().UnixNano()
|
||||
put := func(path string, isDir bool, mtime time.Time) {
|
||||
e := &Entry{FullPath: util.FullPath(path), Attr: Attr{Mtime: mtime}}
|
||||
if isDir {
|
||||
e.Attr.Mode = os.ModeDir
|
||||
}
|
||||
if err := store.InsertEntry(ctx, e); err != nil {
|
||||
t.Fatalf("insert %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
old := time.Unix(1600000000, 0)
|
||||
newer := time.Unix(0, eventTs).Add(time.Second)
|
||||
put("/d", true, old)
|
||||
put("/d/old.txt", false, old)
|
||||
put("/d/sub", true, old)
|
||||
put("/d/sub/newer.txt", false, newer)
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
|
||||
DeleteChunks: false,
|
||||
},
|
||||
TsNs: eventTs,
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.FindEntry(ctx, "/d/old.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
t.Fatalf("older child should be swept, got %v", err)
|
||||
}
|
||||
for _, path := range []string{"/d", "/d/sub", "/d/sub/newer.txt"} {
|
||||
if _, err := store.FindEntry(ctx, util.FullPath(path)); err != nil {
|
||||
t.Fatalf("newer child and its ancestors should survive: %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A directory kept for newer descendants still records the delete: a stale
|
||||
// create replayed later for another child must be fenced by it.
|
||||
func TestReplayKeptDirectoryDeleteFencesStaleCreates(t *testing.T) {
|
||||
store := newStubFilerStore()
|
||||
f := newReplayTestFiler(store)
|
||||
ctx := context.Background()
|
||||
|
||||
eventTs := time.Now().UnixNano()
|
||||
old := time.Unix(1600000000, 0)
|
||||
newer := time.Unix(0, eventTs).Add(time.Second)
|
||||
put := func(path string, isDir bool, mtime time.Time) {
|
||||
e := &Entry{FullPath: util.FullPath(path), Attr: Attr{Mtime: mtime}}
|
||||
if isDir {
|
||||
e.Attr.Mode = os.ModeDir
|
||||
}
|
||||
if err := store.InsertEntry(ctx, e); err != nil {
|
||||
t.Fatalf("insert %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
put("/d", true, old)
|
||||
put("/d/newer.txt", false, newer)
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: "d", IsDirectory: true},
|
||||
},
|
||||
TsNs: eventTs,
|
||||
}
|
||||
if err := f.Replay(event); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(ctx, "/d"); err != nil {
|
||||
t.Fatalf("newer child should keep the directory: %v", err)
|
||||
}
|
||||
|
||||
stale := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/d",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{Name: "late.txt"},
|
||||
},
|
||||
TsNs: eventTs - int64(time.Minute),
|
||||
}
|
||||
if err := f.Replay(stale); err != nil {
|
||||
t.Fatalf("replay stale create: %v", err)
|
||||
}
|
||||
if _, err := store.FindEntry(ctx, "/d/late.txt"); !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
t.Fatal("stale create resurrected a child under a directory the origin deleted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,3 +34,55 @@ func TestOnPeerUpdateRepeatedAdd(t *testing.T) {
|
||||
t.Fatal("expecting the subscription to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown must close every peer's stopChan and wait for the loops, which is
|
||||
// what lets each loop's deferred offset save run before the store closes.
|
||||
func TestMetaAggregatorShutdownStopsPeerLoops(t *testing.T) {
|
||||
ma := NewMetaAggregator(nil, pb.ServerAddress("127.0.0.1:2"), nil)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
peer := pb.ServerAddress("127.0.0.1:1")
|
||||
ma.peerChans[peer] = stopChan
|
||||
ma.peerWg.Add(1)
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer ma.peerWg.Done()
|
||||
<-stopChan
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ma.Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Shutdown did not return")
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
default:
|
||||
t.Fatal("peer loop was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// After Shutdown begins, a peer-add update arriving on the still-connected
|
||||
// master watch must not start a subscription: it would have no stop signal
|
||||
// and could run past the store close.
|
||||
func TestOnPeerUpdateAfterShutdownRejected(t *testing.T) {
|
||||
ma := NewMetaAggregator(nil, pb.ServerAddress("127.0.0.1:2"), nil)
|
||||
ma.Shutdown()
|
||||
|
||||
peer := pb.ServerAddress("127.0.0.1:1")
|
||||
ma.OnPeerUpdate(&master_pb.ClusterNodeUpdate{Address: string(peer), IsAdd: true}, time.Now())
|
||||
|
||||
ma.peerChansLock.Lock()
|
||||
_, found := ma.peerChans[peer]
|
||||
ma.peerChansLock.Unlock()
|
||||
if found {
|
||||
t.Fatal("peer subscription started after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// metaPathTsPrefix keys per-path commit timestamps in the filer store KV:
|
||||
// the newest event timestamp this filer committed or applied for the
|
||||
// path. Written for every emitted mutation event and kept after the entry
|
||||
// is deleted, so a replayed peer event can still be ordered against a
|
||||
// change that left no entry behind.
|
||||
metaPathTsPrefix = "MpTs"
|
||||
// metaPathDelPrefix keys delete-only timestamps. A replayed event checks
|
||||
// the path's ancestors too, so a directory delete also covers children
|
||||
// swept without per-child events (a dropped bucket's whole-store delete,
|
||||
// or a walk aborted partway).
|
||||
metaPathDelPrefix = "MpDl"
|
||||
)
|
||||
|
||||
// metaPathKey hashes the path into a fixed-size key. SQL-backed KV stores
|
||||
// split the key at byte 8 and store the remainder base64-encoded in a
|
||||
// VARCHAR(766) name column: a raw path over ~578 bytes overflows it, the
|
||||
// KvPut insert fails, and the fallback update touches no row yet returns no
|
||||
// error — the commit record would never land and stale replay could restore
|
||||
// the path.
|
||||
func metaPathKey(prefix string, path util.FullPath) []byte {
|
||||
sum := sha256.Sum256([]byte(path))
|
||||
key := make([]byte, len(prefix)+16)
|
||||
copy(key, prefix)
|
||||
copy(key[len(prefix):], sum[:16])
|
||||
return key
|
||||
}
|
||||
|
||||
// kvReadTsNs returns the timestamp recorded for key, or found=false when the
|
||||
// key is absent or malformed. Any other store read failure is an error: a
|
||||
// caller deciding whether a replayed change is stale must not guess.
|
||||
func (f *Filer) kvReadTsNs(ctx context.Context, key []byte) (tsNs int64, found bool, err error) {
|
||||
value, err := f.Store.KvGet(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrKvNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if len(value) < 8 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int64(util.BytesToUint64(value)), true, nil
|
||||
}
|
||||
|
||||
// notePathChange persists tsNs as path's newest committed change. Local event
|
||||
// timestamps are stamped monotonically (metaLogInflight.stamp), so a plain
|
||||
// put cannot regress an earlier local record; a higher peer record it may
|
||||
// overwrite was already newer under last-writer-wins ordering anyway. A
|
||||
// delete additionally writes a delete record the ancestor lookup consults.
|
||||
//
|
||||
// The write uses a fresh context: callers notify after CommitTransaction,
|
||||
// and SQL stores keep the already-committed *sql.Tx in the request context —
|
||||
// a record written through it would fail and the path would lose ordering.
|
||||
//
|
||||
// A failed write is kept in metaTsPending and retried in the background: the
|
||||
// mutation the record orders has already committed, so dropping it would let
|
||||
// a stale replayed change overwrite or resurrect the path while the store is
|
||||
// unreachable. Ordering reads consult the pending record until the retry
|
||||
// lands it.
|
||||
func (f *Filer) notePathChange(path util.FullPath, tsNs int64, deleted bool) {
|
||||
if f.Store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := string(path)
|
||||
rec := f.pendingRec(key)
|
||||
defer rec.mu.Unlock()
|
||||
|
||||
value := make([]byte, 8)
|
||||
util.Uint64toBytes(value, uint64(tsNs))
|
||||
if err := f.Store.KvPut(ctx, metaPathKey(metaPathTsPrefix, path), value); err != nil {
|
||||
glog.WarningfCtx(ctx, "record meta change time %s: %v", path, err)
|
||||
if tsNs > rec.changeTsNs.Load() {
|
||||
rec.changeTsNs.Store(tsNs)
|
||||
}
|
||||
} else if rec.changeTsNs.Load() <= tsNs {
|
||||
// This write covers every change record owed so far.
|
||||
rec.changeTsNs.Store(0)
|
||||
}
|
||||
if deleted {
|
||||
if err := f.Store.KvPut(ctx, metaPathKey(metaPathDelPrefix, path), value); err != nil {
|
||||
glog.WarningfCtx(ctx, "record meta delete time %s: %v", path, err)
|
||||
if tsNs > rec.deleteTsNs.Load() {
|
||||
rec.deleteTsNs.Store(tsNs)
|
||||
}
|
||||
} else if rec.deleteTsNs.Load() <= tsNs {
|
||||
rec.deleteTsNs.Store(0)
|
||||
}
|
||||
}
|
||||
if rec.changeTsNs.Load() == 0 && rec.deleteTsNs.Load() == 0 {
|
||||
f.metaTsPending.CompareAndDelete(key, rec)
|
||||
return
|
||||
}
|
||||
if _, running := f.metaTsRepairing.LoadOrStore(key, struct{}{}); !running {
|
||||
go f.repairMetaPathTs(path)
|
||||
}
|
||||
}
|
||||
|
||||
// metaTsPendingRec is a commit record still on its way to the store, holding
|
||||
// only work not yet written: changeTsNs is the newest committed change the
|
||||
// MpTs record lacks and deleteTsNs the newest delete the MpDl record lacks —
|
||||
// the two keep their own times so a later update cannot masquerade as the
|
||||
// delete. Store writes and the bookkeeping they discharge share mu, so a
|
||||
// repair pass can never write an older record over a newer one that already
|
||||
// landed; readers take the atomic fields directly.
|
||||
type metaTsPendingRec struct {
|
||||
mu sync.Mutex
|
||||
changeTsNs atomic.Int64
|
||||
deleteTsNs atomic.Int64
|
||||
}
|
||||
|
||||
// pendingRec returns the live pending record for key, locked for mutation,
|
||||
// recreating it when a repair already dropped the previous one.
|
||||
func (f *Filer) pendingRec(key string) *metaTsPendingRec {
|
||||
for {
|
||||
cur, _ := f.metaTsPending.LoadOrStore(key, &metaTsPendingRec{})
|
||||
rec := cur.(*metaTsPendingRec)
|
||||
rec.mu.Lock()
|
||||
if live, ok := f.metaTsPending.Load(key); ok && live == cur {
|
||||
return rec
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// repairMetaPathTs retries a pending commit record until it lands. Each pass
|
||||
// writes only what the record still owes, so a merge by a later failure is
|
||||
// picked up without another goroutine and a landed write is never rewritten.
|
||||
// The retry is unbounded — the mutation is already committed and only the
|
||||
// record preserves its ordering.
|
||||
func (f *Filer) repairMetaPathTs(path util.FullPath) {
|
||||
key := string(path)
|
||||
ctx := f.flushCtx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
defer func() {
|
||||
f.metaTsRepairing.Delete(key)
|
||||
// A failure landing between the last pass and the flag release
|
||||
// still needs a repair pass — unless the retry context is done.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if _, ok := f.metaTsPending.Load(key); ok {
|
||||
if _, running := f.metaTsRepairing.LoadOrStore(key, struct{}{}); !running {
|
||||
go f.repairMetaPathTs(path)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
if _, ok := f.metaTsPending.Load(key); !ok {
|
||||
return
|
||||
}
|
||||
rec := f.pendingRec(key)
|
||||
var tsErr, delErr error
|
||||
if tsNs := rec.changeTsNs.Load(); tsNs > 0 {
|
||||
value := make([]byte, 8)
|
||||
util.Uint64toBytes(value, uint64(tsNs))
|
||||
if tsErr = f.Store.KvPut(ctx, metaPathKey(metaPathTsPrefix, path), value); tsErr == nil {
|
||||
rec.changeTsNs.Store(0)
|
||||
}
|
||||
}
|
||||
if tsNs := rec.deleteTsNs.Load(); tsNs > 0 {
|
||||
value := make([]byte, 8)
|
||||
util.Uint64toBytes(value, uint64(tsNs))
|
||||
if delErr = f.Store.KvPut(ctx, metaPathKey(metaPathDelPrefix, path), value); delErr == nil {
|
||||
rec.deleteTsNs.Store(0)
|
||||
}
|
||||
}
|
||||
done := rec.changeTsNs.Load() == 0 && rec.deleteTsNs.Load() == 0
|
||||
if done {
|
||||
f.metaTsPending.CompareAndDelete(key, rec)
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
if !done {
|
||||
glog.V(0).InfofCtx(ctx, "retry record meta change time %s: %v %v", path, tsErr, delErr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pendingPathTsNs returns the newest pending commit timestamp for path and
|
||||
// the newest pending delete timestamp.
|
||||
func (f *Filer) pendingPathTsNs(path util.FullPath) (changeTsNs, deleteTsNs int64) {
|
||||
cur, ok := f.metaTsPending.Load(string(path))
|
||||
if !ok {
|
||||
return 0, 0
|
||||
}
|
||||
rec := cur.(*metaTsPendingRec)
|
||||
return rec.changeTsNs.Load(), rec.deleteTsNs.Load()
|
||||
}
|
||||
|
||||
// CommitTsNs returns the newest commit timestamp known for path — the
|
||||
// recorded event time, or a still-pending one — and fallbackTsNs when none
|
||||
// exists. Peers stamp traversal entries with it so a bootstrap subscriber
|
||||
// orders each entry on the sender's commit clock rather than the entry's
|
||||
// client-settable mtime. A store read failure is an error, not a fallback:
|
||||
// the mtime it would mint could outdate real commits.
|
||||
func (f *Filer) CommitTsNs(ctx context.Context, path util.FullPath, fallbackTsNs int64) (int64, error) {
|
||||
if f.Store == nil {
|
||||
return fallbackTsNs, nil
|
||||
}
|
||||
tsNs := fallbackTsNs
|
||||
recTs, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathTsPrefix, path))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found {
|
||||
tsNs = recTs
|
||||
}
|
||||
if pendTs, _ := f.pendingPathTsNs(path); pendTs > tsNs {
|
||||
tsNs = pendTs
|
||||
}
|
||||
return tsNs, nil
|
||||
}
|
||||
|
||||
// noteCommittedEvent records the event's commit timestamp for every path it
|
||||
// touches: the source of a delete or rename, and the target of a create,
|
||||
// update, or rename. It runs after the local mutation commits while the
|
||||
// caller still holds the path's entry lock (for filer-server mutations), so
|
||||
// a peer replay ordered by the same lock sees either the whole local change
|
||||
// or none of it.
|
||||
func (f *Filer) noteCommittedEvent(event *filer_pb.SubscribeMetadataResponse) {
|
||||
message := event.EventNotification
|
||||
if message == nil {
|
||||
return
|
||||
}
|
||||
var source, target util.FullPath
|
||||
if message.OldEntry != nil {
|
||||
source = util.FullPath(filer_pb.MetadataEventSourceFullPath(event))
|
||||
}
|
||||
if message.NewEntry != nil {
|
||||
target = util.FullPath(filer_pb.MetadataEventTargetFullPath(event))
|
||||
}
|
||||
// A delete or a rename removes the source path; an update reports the
|
||||
// same path on both sides and counts once.
|
||||
if source != "" && source != target {
|
||||
f.notePathChange(source, event.TsNs, true)
|
||||
}
|
||||
if target != "" {
|
||||
f.notePathChange(target, event.TsNs, false)
|
||||
}
|
||||
}
|
||||
|
||||
// deletedAfterTsNs returns the newest recorded delete timestamp among path
|
||||
// and its ancestors.
|
||||
func (f *Filer) deletedAfterTsNs(ctx context.Context, path util.FullPath) (int64, error) {
|
||||
var tsNs int64
|
||||
for p := string(path); p != "" && p != "/"; {
|
||||
ts, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathDelPrefix, util.FullPath(p)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found && ts > tsNs {
|
||||
tsNs = ts
|
||||
}
|
||||
if _, pendDel := f.pendingPathTsNs(util.FullPath(p)); pendDel > tsNs {
|
||||
tsNs = pendDel
|
||||
}
|
||||
i := strings.LastIndexByte(p, '/')
|
||||
if i <= 0 {
|
||||
break
|
||||
}
|
||||
p = p[:i]
|
||||
}
|
||||
return tsNs, nil
|
||||
}
|
||||
|
||||
// localCommitTsNs returns the newest timestamp this filer is known to have
|
||||
// committed for path: the recorded event time when one exists — it is not
|
||||
// client-controllable the way an entry's mtime is — else the entry's mtime
|
||||
// for entries written before recording existed, plus any recorded delete of
|
||||
// the path or an ancestor.
|
||||
func (f *Filer) localCommitTsNs(ctx context.Context, path util.FullPath) (int64, error) {
|
||||
if f.Store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var tsNs int64
|
||||
recTs, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathTsPrefix, path))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found {
|
||||
tsNs = recTs
|
||||
} else if entry, findErr := f.Store.FindEntry(ctx, path); findErr != nil {
|
||||
if !errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
return 0, findErr
|
||||
}
|
||||
} else if entry != nil {
|
||||
tsNs = entry.Mtime.UnixNano()
|
||||
}
|
||||
if pendTs, _ := f.pendingPathTsNs(path); pendTs > tsNs {
|
||||
tsNs = pendTs
|
||||
}
|
||||
delTs, err := f.deletedAfterTsNs(ctx, path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delTs > tsNs {
|
||||
tsNs = delTs
|
||||
}
|
||||
return tsNs, nil
|
||||
}
|
||||
+261
-14
@@ -2,6 +2,9 @@ package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -9,31 +12,275 @@ import (
|
||||
)
|
||||
|
||||
// Replay applies the delete and the insert as separate, non-transactional
|
||||
// store calls, so retrying it after a partial failure is not atomic.
|
||||
func Replay(filerStore FilerStore, resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
// store calls, so retrying it after a partial failure is not atomic; both
|
||||
// operations are idempotent and the timestamp check tolerates re-delivery.
|
||||
//
|
||||
// A replayed event can be older than local state: a peer resuming from a
|
||||
// stale offset replays everything since the offset, and two writes through
|
||||
// different filers race within the replication lag. Apply each path's change
|
||||
// only when no newer local change is recorded, so a late event cannot revert
|
||||
// or resurrect entries.
|
||||
//
|
||||
// Ordering uses the per-path commit timestamp recorded for every emitted
|
||||
// mutation (meta_path_ts.go), which survives deletes as a tombstone and is
|
||||
// not client-controllable the way entry mtime is — a mounted client can set
|
||||
// mtime into the future, and a rename keeps the source file's mtime on the
|
||||
// destination entry. Entries written before recording existed fall back to
|
||||
// their mtime.
|
||||
//
|
||||
// The check and the mutation run under the same per-path entry lock the
|
||||
// filer server's local writes hold, so they cannot be split by a concurrent
|
||||
// local update or by another peer's replay.
|
||||
func (f *Filer) Replay(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
message := resp.EventNotification
|
||||
var oldPath util.FullPath
|
||||
var newEntry *Entry
|
||||
dir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
var oldPath, newPath util.FullPath
|
||||
if message.OldEntry != nil {
|
||||
oldPath = util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
if err := filerStore.DeleteEntry(context.Background(), oldPath); err != nil {
|
||||
}
|
||||
if message.NewEntry != nil {
|
||||
newPath = util.NewFullPath(dir, message.NewEntry.Name)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
unlock := f.lockReplayPaths(oldPath, newPath)
|
||||
defer unlock()
|
||||
|
||||
samePath := oldPath != "" && oldPath == newPath
|
||||
if samePath {
|
||||
newer, err := f.replayBlockedByLocal(ctx, oldPath, resp.TsNs)
|
||||
if err != nil || newer {
|
||||
return err
|
||||
}
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record the commit before the insert too: if the insert fails and
|
||||
// the event is retried later, the path sits deleted in between —
|
||||
// without the record a stale peer create could resurrect it in the
|
||||
// gap. The entry still exists afterward, so no delete record.
|
||||
f.notePathChange(oldPath, resp.TsNs, false)
|
||||
glog.V(4).Infof("creating %v", newPath)
|
||||
newEntry := FromPbEntry(dir, message.NewEntry)
|
||||
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
f.notePathChange(oldPath, resp.TsNs, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
if message.OldEntry != nil {
|
||||
newer, err := f.replayBlockedByLocal(ctx, oldPath, resp.TsNs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !newer {
|
||||
// A directory delete on the origin removed the whole subtree, but
|
||||
// only the directory's own key replicates. Sweep local children the
|
||||
// origin never listed, or they orphan under a missing parent.
|
||||
keepDir := false
|
||||
if message.OldEntry.IsDirectory {
|
||||
var err error
|
||||
keepDir, err = f.deleteReplayedFolderChildren(ctx, oldPath, message.DeleteChunks, resp.TsNs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !keepDir {
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The origin deleted the directory even when a newer descendant
|
||||
// keeps its entry here — recording the delete fences stale child
|
||||
// creates that would otherwise resurrect under it.
|
||||
f.notePathChange(oldPath, resp.TsNs, true)
|
||||
}
|
||||
}
|
||||
|
||||
if message.NewEntry != nil {
|
||||
dir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
key := util.NewFullPath(dir, message.NewEntry.Name)
|
||||
glog.V(4).Infof("creating %v", key)
|
||||
newEntry = FromPbEntry(dir, message.NewEntry)
|
||||
if err := filerStore.InsertEntry(context.Background(), newEntry); err != nil {
|
||||
newer, err := f.replayBlockedByLocal(ctx, newPath, resp.TsNs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !newer {
|
||||
glog.V(4).Infof("creating %v", newPath)
|
||||
newEntry := FromPbEntry(dir, message.NewEntry)
|
||||
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
f.notePathChange(newPath, resp.TsNs, false)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteReplayedFolderChildren removes local entries under dir, and queues
|
||||
// the chunks of removed files for deletion. Children this filer committed
|
||||
// after the event stay — a newer local write wins over a delayed delete —
|
||||
// along with the directories that hold them.
|
||||
func (f *Filer) deleteReplayedFolderChildren(ctx context.Context, dir util.FullPath, deleteChunks bool, tsNs int64) (bool, error) {
|
||||
sweep := &folderSweep{chunksByPath: make(map[util.FullPath][]*filer_pb.FileChunk)}
|
||||
kept, err := f.sweepReplayedFolder(ctx, dir, deleteChunks, tsNs, sweep)
|
||||
// Flush what was collected even on error: the entries already deleted
|
||||
// cannot be listed again on retry, so their chunks must be queued now.
|
||||
sweep.flush(ctx, f)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// folderSweep accumulates the chunks of removed children for deletion. It
|
||||
// flushes in batches so a large subtree cannot hold every chunk reference in
|
||||
// memory at once, and so chunk queueing overlaps the listing work.
|
||||
type folderSweep struct {
|
||||
chunksByPath map[util.FullPath][]*filer_pb.FileChunk
|
||||
count int
|
||||
}
|
||||
|
||||
const folderSweepChunkBatch = 4096
|
||||
|
||||
func (s *folderSweep) add(path util.FullPath, chunks []*filer_pb.FileChunk) {
|
||||
s.chunksByPath[path] = append(s.chunksByPath[path], chunks...)
|
||||
s.count += len(chunks)
|
||||
}
|
||||
|
||||
func (s *folderSweep) flush(ctx context.Context, f *Filer) {
|
||||
for path, chunks := range s.chunksByPath {
|
||||
f.DeleteChunks(ctx, path, chunks)
|
||||
}
|
||||
s.chunksByPath = make(map[util.FullPath][]*filer_pb.FileChunk)
|
||||
s.count = 0
|
||||
}
|
||||
|
||||
// sweepReplayedFolder deletes every child of dir whose local commit is not
|
||||
// newer than the event, working subtree-first so nested entries go too.
|
||||
// It reports whether a newer descendant keeps dir alive — the caller then
|
||||
// keeps the dir entry itself.
|
||||
func (f *Filer) sweepReplayedFolder(ctx context.Context, dir util.FullPath, deleteChunks bool, tsNs int64, sweep *folderSweep) (kept bool, err error) {
|
||||
lastFileName := ""
|
||||
for {
|
||||
entries, _, lerr := f.ListDirectoryEntries(ctx, dir, lastFileName, false, PaginationSize, "", "", "")
|
||||
if lerr != nil {
|
||||
return false, fmt.Errorf("list folder %s: %w", dir, lerr)
|
||||
}
|
||||
for _, sub := range entries {
|
||||
lastFileName = sub.Name()
|
||||
childKept := false
|
||||
if tsNs > 0 {
|
||||
localTsNs, tsErr := f.localCommitTsNs(ctx, sub.FullPath)
|
||||
if tsErr != nil {
|
||||
return false, tsErr
|
||||
}
|
||||
childKept = localTsNs > tsNs
|
||||
}
|
||||
if !childKept && sub.IsDirectory() {
|
||||
childKept, err = f.sweepReplayedFolder(ctx, sub.FullPath, deleteChunks, tsNs, sweep)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if childKept {
|
||||
kept = true
|
||||
continue
|
||||
}
|
||||
if derr := f.Store.DeleteEntry(ctx, sub.FullPath); derr != nil {
|
||||
return false, fmt.Errorf("delete %s: %w", sub.FullPath, derr)
|
||||
}
|
||||
f.notePathChange(sub.FullPath, tsNs, true)
|
||||
// hard-linked chunks are owned by the shared inode record; the
|
||||
// origin's own events decide their deletion. A directory entry
|
||||
// can still carry chunks of its own — an S3 child promotes an
|
||||
// existing file to a directory without dropping them — so the
|
||||
// entry's chunks are keyed to its own path whatever it is.
|
||||
if deleteChunks && len(sub.HardLinkId) == 0 && len(sub.GetChunks()) > 0 {
|
||||
sweep.add(sub.FullPath, sub.GetChunks())
|
||||
if sweep.count >= folderSweepChunkBatch {
|
||||
sweep.flush(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(entries) < PaginationSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// replayBlockedByLocal reports whether the local store already holds a
|
||||
// version of path committed after the event's timestamp. Lookup failures are
|
||||
// propagated: a replayed change must not be applied on the guess that the
|
||||
// path was simply absent, or a transient read error could let a stale event
|
||||
// overwrite a newer entry past the point of no return.
|
||||
func (f *Filer) replayBlockedByLocal(ctx context.Context, path util.FullPath, tsNs int64) (bool, error) {
|
||||
if tsNs <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
localTsNs, err := f.localCommitTsNs(ctx, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return localTsNs > tsNs, nil
|
||||
}
|
||||
|
||||
// lockReplayPaths holds the shared entry locks for every path the event
|
||||
// touches, in the same sorted order the rename handler takes them.
|
||||
// lockReplayPaths takes the per-path entry locks for the event's paths and
|
||||
// every ancestor of them, in lexicographic order — parents sort before
|
||||
// children, the same ordering the local rename handler's two-path lock uses,
|
||||
// so lock acquisition is deadlock-free across operations. Ancestors are
|
||||
// included because the replay check consults ancestor delete records: a
|
||||
// local recursive delete or directory rename holds only the ancestor's lock,
|
||||
// and without contending on it a replay could reinsert a stale child after
|
||||
// its check. Ancestors are locked shared — they only need to exclude an
|
||||
// exclusive holder, not each other, or every replay under a busy parent
|
||||
// would serialize — while a path the event actually changes stays exclusive
|
||||
// even when it is also the other path's ancestor. The root is never locked:
|
||||
// it cannot be deleted, and a shared root lock would still be excluded by
|
||||
// every top-level delete.
|
||||
func (f *Filer) lockReplayPaths(oldPath, newPath util.FullPath) func() {
|
||||
lt := f.EntryLockTable
|
||||
if lt == nil {
|
||||
return func() {}
|
||||
}
|
||||
var paths []util.FullPath
|
||||
addAncestors := func(p util.FullPath) {
|
||||
for i := strings.LastIndexByte(string(p), '/'); i > 0; i = strings.LastIndexByte(string(p), '/') {
|
||||
p = p[:i]
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
addAncestors(oldPath)
|
||||
addAncestors(newPath)
|
||||
if oldPath != "" {
|
||||
paths = append(paths, oldPath)
|
||||
}
|
||||
if newPath != "" {
|
||||
paths = append(paths, newPath)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return func() {}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
paths = slices.Compact(paths)
|
||||
locks := make([]*util.ActiveLock, len(paths))
|
||||
for i, p := range paths {
|
||||
lockType := util.SharedLock
|
||||
if p == oldPath || p == newPath {
|
||||
lockType = util.ExclusiveLock
|
||||
}
|
||||
locks[i] = lt.AcquireLock("MetaReplay", p, lockType)
|
||||
}
|
||||
return func() {
|
||||
for i := len(paths) - 1; i >= 0; i-- {
|
||||
lt.ReleaseLock(paths[i], locks[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ func (gen *SqlGenMysql) nameExpr() string {
|
||||
|
||||
func (gen *SqlGenMysql) GetSqlListExclusive(tableName string) string {
|
||||
name := gen.nameExpr()
|
||||
return fmt.Sprintf("SELECT `name`, `meta` FROM `%s` WHERE `dirhash` = ? AND %s > ? AND `directory` = ? AND %s LIKE ? ORDER BY %s ASC LIMIT ?", tableName, name, name, name)
|
||||
return fmt.Sprintf("SELECT `name`, `meta` FROM `%s` WHERE `dirhash` = ? AND %s > ? AND `directory` = ? AND %s LIKE ? ESCAPE '!' ORDER BY %s ASC LIMIT ?", tableName, name, name, name)
|
||||
}
|
||||
|
||||
func (gen *SqlGenMysql) GetSqlListInclusive(tableName string) string {
|
||||
name := gen.nameExpr()
|
||||
return fmt.Sprintf("SELECT `name`, `meta` FROM `%s` WHERE `dirhash` = ? AND %s >= ? AND `directory` = ? AND %s LIKE ? ORDER BY %s ASC LIMIT ?", tableName, name, name, name)
|
||||
return fmt.Sprintf("SELECT `name`, `meta` FROM `%s` WHERE `dirhash` = ? AND %s >= ? AND `directory` = ? AND %s LIKE ? ESCAPE '!' ORDER BY %s ASC LIMIT ?", tableName, name, name, name)
|
||||
}
|
||||
|
||||
func (gen *SqlGenMysql) GetSqlCreateTable(tableName string) string {
|
||||
|
||||
@@ -65,6 +65,15 @@ func TestListSqlBinaryOrderingOnNonBinaryColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSqlEscapesLikeWildcards(t *testing.T) {
|
||||
gen := &SqlGenMysql{}
|
||||
for _, got := range []string{gen.GetSqlListExclusive("filemeta"), gen.GetSqlListInclusive("filemeta")} {
|
||||
if !strings.Contains(got, "LIKE ? ESCAPE '!'") {
|
||||
t.Fatalf("list query must declare the escape char matching LikePattern, got: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBinaryCollation(t *testing.T) {
|
||||
binary := []string{"", "binary", "utf8mb4_bin", "utf8mb3_bin", "latin1_bin", "UTF8MB4_BIN"}
|
||||
for _, c := range binary {
|
||||
|
||||
@@ -77,12 +77,12 @@ func (gen *SqlGenPostgres) nameExpr() string {
|
||||
|
||||
func (gen *SqlGenPostgres) GetSqlListExclusive(tableName string) string {
|
||||
name := gen.nameExpr()
|
||||
return fmt.Sprintf(`SELECT NAME, meta FROM "%s" WHERE dirhash=$1 AND %s>$2 AND directory=$3 AND %s like $4 ORDER BY %s ASC LIMIT $5`, tableName, name, name, name)
|
||||
return fmt.Sprintf(`SELECT NAME, meta FROM "%s" WHERE dirhash=$1 AND %s>$2 AND directory=$3 AND %s like $4 ESCAPE '!' ORDER BY %s ASC LIMIT $5`, tableName, name, name, name)
|
||||
}
|
||||
|
||||
func (gen *SqlGenPostgres) GetSqlListInclusive(tableName string) string {
|
||||
name := gen.nameExpr()
|
||||
return fmt.Sprintf(`SELECT NAME, meta FROM "%s" WHERE dirhash=$1 AND %s>=$2 AND directory=$3 AND %s like $4 ORDER BY %s ASC LIMIT $5`, tableName, name, name, name)
|
||||
return fmt.Sprintf(`SELECT NAME, meta FROM "%s" WHERE dirhash=$1 AND %s>=$2 AND directory=$3 AND %s like $4 ESCAPE '!' ORDER BY %s ASC LIMIT $5`, tableName, name, name, name)
|
||||
}
|
||||
|
||||
func (gen *SqlGenPostgres) GetSqlCreateTable(tableName string) string {
|
||||
|
||||
@@ -62,6 +62,15 @@ func TestListSqlBinaryOrderingOnNonBinaryColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSqlEscapesLikeWildcards(t *testing.T) {
|
||||
gen := &SqlGenPostgres{}
|
||||
for _, got := range []string{gen.GetSqlListExclusive("filemeta"), gen.GetSqlListInclusive("filemeta")} {
|
||||
if !strings.Contains(got, "like $4 ESCAPE '!'") {
|
||||
t.Fatalf("list query must declare the escape char matching LikePattern, got: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsByteOrderedCollation(t *testing.T) {
|
||||
byteOrdered := []string{"C", "POSIX", "c", "C.UTF-8", "C.utf8"}
|
||||
for _, c := range byteOrdered {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/chunk_cache"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
// A sequential reader that consumes a chunk in several small ReadAt calls, as
|
||||
// the S3 GET path does with its 256 KiB copy buffer over 4 MiB chunks, must
|
||||
// keep its read-ahead at prefetchCount chunks. Each ReadAt triggers a
|
||||
// prefetch; if chunks already being fetched are not counted toward the
|
||||
// window, every call starts prefetchCount more downloads and the read-ahead
|
||||
// runs to the ReaderCache downloader limit, buffering most of the object.
|
||||
func TestChunkReadAtSequentialPrefetchStaysWithinWindow(t *testing.T) {
|
||||
const chunkSize = 64 << 10
|
||||
const chunkCount = 64
|
||||
const sliceSize = 16 << 10
|
||||
|
||||
var readerChunk atomic.Int64
|
||||
var maxLead atomic.Int64
|
||||
rc := NewReaderCache(256, (*chunk_cache.TieredChunkCache)(nil), func(context.Context, string) ([]string, error) {
|
||||
return []string{"unused"}, nil
|
||||
}, nil)
|
||||
defer rc.destroy()
|
||||
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, fileId string, _ util_http.RefreshUrlsFunc) (int, error) {
|
||||
idx, err := strconv.Atoi(strings.TrimPrefix(fileId, "chunk"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lead := int64(idx) - readerChunk.Load()
|
||||
for {
|
||||
cur := maxLead.Load()
|
||||
if lead <= cur || maxLead.CompareAndSwap(cur, lead) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
views := NewIntervalList[*ChunkView]()
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
views.AppendInterval(&Interval[*ChunkView]{
|
||||
StartOffset: int64(i * chunkSize),
|
||||
StopOffset: int64((i + 1) * chunkSize),
|
||||
Value: &ChunkView{
|
||||
FileId: fmt.Sprintf("chunk%d", i),
|
||||
ViewSize: chunkSize,
|
||||
ViewOffset: int64(i * chunkSize),
|
||||
ChunkSize: chunkSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
reader := NewChunkReaderAtFromClient(context.Background(), rc, views, chunkSize*chunkCount, DefaultPrefetchCount)
|
||||
defer reader.ReleaseStream()
|
||||
|
||||
buf := make([]byte, sliceSize)
|
||||
for off := int64(0); off < chunkSize*chunkCount; off += sliceSize {
|
||||
readerChunk.Store(off / chunkSize)
|
||||
if _, err := reader.ReadAt(buf, off); err != nil && off+sliceSize < chunkSize*chunkCount {
|
||||
t.Fatalf("ReadAt(%d): %v", off, err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := maxLead.Load(); got > DefaultPrefetchCount {
|
||||
t.Fatalf("read-ahead reached %d chunks past the reader, want at most %d", got, DefaultPrefetchCount)
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,10 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
|
||||
return
|
||||
}
|
||||
|
||||
cached := 0
|
||||
for x := chunkViews; x != nil && cached < count; x = x.Next {
|
||||
// The window is the next count chunks, whether or not a download for them
|
||||
// is already running: counting only newly started downloads would push the
|
||||
// window count chunks further on every call.
|
||||
for x, i := chunkViews, 0; x != nil && i < count; x, i = x.Next, i+1 {
|
||||
chunkView := x.Value
|
||||
if _, found := rc.downloaders[chunkView.FileId]; found {
|
||||
continue
|
||||
@@ -124,7 +126,6 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
|
||||
rc.downloaders[chunkView.FileId] = cacher
|
||||
go cacher.startCaching()
|
||||
started = append(started, cacher)
|
||||
cached++
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build sqlite
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer/mysql"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestListDirectoryPrefixedEntriesEscapesLikeWildcards(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE filemeta (dirhash BIGINT, name VARCHAR(1000), directory TEXT, meta BLOB, PRIMARY KEY (dirhash, name))`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := &SqliteStore{}
|
||||
store.SqlGenerator = &mysql.SqlGenMysql{}
|
||||
store.DB = db
|
||||
|
||||
ctx := context.Background()
|
||||
names := []string{
|
||||
"call_03_KNlHPt6grkZMe4WZVwQi9710", // target: literal _ prefix
|
||||
"callX03XKNlZZZ", // would match if _ were a wildcard
|
||||
"call_99_other",
|
||||
"100%_done", // % and _ in the name
|
||||
"100aXdone", // would match prefix "100%_" if unescaped
|
||||
"bang!file",
|
||||
}
|
||||
for _, n := range names {
|
||||
entry := &filer.Entry{FullPath: util.FullPath("/dir/" + n)}
|
||||
if err := store.InsertEntry(ctx, entry); err != nil {
|
||||
t.Fatalf("insert %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
list := func(prefix string) []string {
|
||||
var got []string
|
||||
_, err := store.ListDirectoryPrefixedEntries(ctx, "/dir", "", false, 100, prefix, func(e *filer.Entry) (bool, error) {
|
||||
got = append(got, e.Name())
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list %q: %v", prefix, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
if got := list("call_03_"); len(got) != 1 || got[0] != "call_03_KNlHPt6grkZMe4WZVwQi9710" {
|
||||
t.Fatalf("prefix with _: got %v", got)
|
||||
}
|
||||
if got := list("call_"); len(got) != 2 {
|
||||
t.Fatalf("prefix call_: got %v", got)
|
||||
}
|
||||
if got := list("100%_"); len(got) != 1 || got[0] != "100%_done" {
|
||||
t.Fatalf("prefix with %%: got %v", got)
|
||||
}
|
||||
if got := list("bang!"); len(got) != 1 || got[0] != "bang!file" {
|
||||
t.Fatalf("prefix with !: got %v", got)
|
||||
}
|
||||
if got := list(""); len(got) != len(names) {
|
||||
t.Fatalf("empty prefix: got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ const (
|
||||
|
||||
SELECT name, meta
|
||||
FROM ` + asql.DEFAULT_TABLE + `
|
||||
WHERE dir_hash = $dir_hash AND directory = $directory and name > $start_name and name LIKE $prefix
|
||||
WHERE dir_hash = $dir_hash AND directory = $directory and name > $start_name and name LIKE $prefix ESCAPE '!'
|
||||
ORDER BY name ASC LIMIT $limit;`
|
||||
|
||||
listInclusiveDirectoryQuery = `
|
||||
@@ -68,6 +68,6 @@ const (
|
||||
|
||||
SELECT name, meta
|
||||
FROM ` + asql.DEFAULT_TABLE + `
|
||||
WHERE dir_hash = $dir_hash AND directory = $directory and name >= $start_name and name LIKE $prefix
|
||||
WHERE dir_hash = $dir_hash AND directory = $directory and name >= $start_name and name LIKE $prefix ESCAPE '!'
|
||||
ORDER BY name ASC LIMIT $limit;`
|
||||
)
|
||||
|
||||
@@ -288,7 +288,7 @@ func (store *YdbStore) ListDirectoryPrefixedEntries(ctx context.Context, dirPath
|
||||
table.ValueParam("$dir_hash", types.Int64Value(util.HashStringToLong(*shortDir))),
|
||||
table.ValueParam("$directory", types.UTF8Value(*shortDir)),
|
||||
table.ValueParam("$start_name", types.UTF8Value(startFileName)),
|
||||
table.ValueParam("$prefix", types.UTF8Value(prefix+"%")),
|
||||
table.ValueParam("$prefix", types.UTF8Value(abstract_sql.LikePattern(prefix))),
|
||||
table.ValueParam("$limit", types.Uint64Value(uint64(chunkLimit))),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ZipExtractMaxMembers bounds how many members one archive write fans out to.
|
||||
ZipExtractMaxMembers = 100000
|
||||
// ZipExtractMaxBytes bounds the total uncompressed bytes one archive may declare.
|
||||
ZipExtractMaxBytes = int64(1) << 40 // 1 TiB
|
||||
|
||||
// ZipExtractMaxArchiveBytes bounds the compressed bytes one archive may
|
||||
// spool to local disk when the caller passes no tighter limit. It matches
|
||||
// the TUS default max upload size.
|
||||
ZipExtractMaxArchiveBytes = int64(5) << 30 // 5 GiB
|
||||
|
||||
)
|
||||
|
||||
// zipSpoolMemoryLimit caps how much of one archive is buffered in memory
|
||||
// before the spool falls through to a temp file. It is a var rather than a
|
||||
// const so tests can shrink it: exercising the temp-file path with the real
|
||||
// 32 MiB threshold allocates hundreds of MiB, which tips the 32-bit test
|
||||
// binary over its 4 GiB address space.
|
||||
var zipSpoolMemoryLimit int64 = 32 << 20 // 32 MiB
|
||||
|
||||
// ErrZipTooLarge marks a spool failure caused by the archive exceeding the
|
||||
// caller's size limit, so handlers can answer 4xx instead of 5xx.
|
||||
var ErrZipTooLarge = errors.New("zip archive too large")
|
||||
|
||||
// SpoolZipReader buffers r into a seekable source for zip.NewReader: memory for
|
||||
// small archives, a temp file otherwise. At most maxArchiveBytes are buffered;
|
||||
// a non-positive limit falls back to ZipExtractMaxArchiveBytes. The returned
|
||||
// cleanup releases the spool.
|
||||
func SpoolZipReader(r io.Reader, maxArchiveBytes int64) (ra io.ReaderAt, n int64, cleanup func(), err error) {
|
||||
nop := func() {}
|
||||
if maxArchiveBytes <= 0 {
|
||||
maxArchiveBytes = ZipExtractMaxArchiveBytes
|
||||
}
|
||||
head, err := io.ReadAll(io.LimitReader(r, zipSpoolMemoryLimit+1))
|
||||
if err != nil {
|
||||
return nil, 0, nop, err
|
||||
}
|
||||
if int64(len(head)) > maxArchiveBytes {
|
||||
return nil, 0, nop, fmt.Errorf("%w: exceeds %d bytes", ErrZipTooLarge, maxArchiveBytes)
|
||||
}
|
||||
if int64(len(head)) <= zipSpoolMemoryLimit {
|
||||
return bytes.NewReader(head), int64(len(head)), nop, nil
|
||||
}
|
||||
f, err := os.CreateTemp("", "seaweedfs-extract-*.zip")
|
||||
if err != nil {
|
||||
return nil, 0, nop, err
|
||||
}
|
||||
cleanup = func() {
|
||||
name := f.Name()
|
||||
f.Close()
|
||||
os.Remove(name)
|
||||
}
|
||||
if _, err = f.Write(head); err == nil {
|
||||
var rest int64
|
||||
// One byte past the limit so an oversized archive errors out instead
|
||||
// of being silently truncated.
|
||||
if rest, err = io.Copy(f, io.LimitReader(r, maxArchiveBytes+1-int64(len(head)))); err == nil {
|
||||
n = int64(len(head)) + rest
|
||||
}
|
||||
}
|
||||
if err == nil && n > maxArchiveBytes {
|
||||
err = fmt.Errorf("%w: exceeds %d bytes", ErrZipTooLarge, maxArchiveBytes)
|
||||
}
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, 0, nop, err
|
||||
}
|
||||
return f, n, cleanup, nil
|
||||
}
|
||||
|
||||
// CheckZipLimits enforces the member-count and total-size caps on an opened archive.
|
||||
func CheckZipLimits(zr *zip.Reader) error {
|
||||
if len(zr.File) > ZipExtractMaxMembers {
|
||||
return fmt.Errorf("zip archive has %d members, limit %d", len(zr.File), ZipExtractMaxMembers)
|
||||
}
|
||||
var total uint64
|
||||
for _, f := range zr.File {
|
||||
if f.UncompressedSize64 > uint64(ZipExtractMaxBytes)-total {
|
||||
return fmt.Errorf("zip archive uncompressed size exceeds %d bytes", ZipExtractMaxBytes)
|
||||
}
|
||||
total += f.UncompressedSize64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeZipMemberName converts a zip member name into a relative filer path.
|
||||
// ok=false means the member must be skipped: absolute or escaping paths,
|
||||
// non-regular files such as symlinks and devices.
|
||||
func SanitizeZipMemberName(f *zip.File) (name string, ok bool) {
|
||||
mode := f.FileInfo().Mode()
|
||||
if mode&(os.ModeSymlink|os.ModeDevice|os.ModeNamedPipe|os.ModeSocket) != 0 {
|
||||
return "", false
|
||||
}
|
||||
name = strings.TrimSuffix(strings.ReplaceAll(f.Name, "\\", "/"), "/")
|
||||
if name == "" || strings.HasPrefix(name, "/") {
|
||||
return "", false
|
||||
}
|
||||
for _, seg := range strings.Split(name, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." || strings.ContainsRune(seg, '\x00') {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
// filepath.IsLocal is the check static analysis recognizes: no element may
|
||||
// be "." or "..", the path may not be absolute or empty, and on Windows it
|
||||
// may not be a reserved name.
|
||||
if !filepath.IsLocal(name) {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func zipFile(t *testing.T, name string, mode os.FileMode) *zip.File {
|
||||
t.Helper()
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store, Modified: time.Unix(1700000000, 0)}
|
||||
h.SetMode(mode)
|
||||
return &zip.File{FileHeader: *h}
|
||||
}
|
||||
|
||||
func TestSanitizeZipMemberName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"a.png", 0644, "a.png", true},
|
||||
{"dir/b.txt", 0644, "dir/b.txt", true},
|
||||
{"dir\\b.txt", 0644, "dir/b.txt", true},
|
||||
{"dir/", os.ModeDir | 0755, "dir", true},
|
||||
{"../escape.txt", 0644, "", false},
|
||||
{"a/../b.txt", 0644, "", false},
|
||||
{"./dot.txt", 0644, "", false},
|
||||
{"/abs.txt", 0644, "", false},
|
||||
{"a//b.txt", 0644, "", false},
|
||||
{"", 0644, "", false},
|
||||
{"link.txt", os.ModeSymlink | 0777, "", false},
|
||||
{"dev", os.ModeDevice | 0666, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := SanitizeZipMemberName(zipFile(t, tt.name, tt.mode))
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("SanitizeZipMemberName(%q, %v) = (%q, %v), want (%q, %v)", tt.name, tt.mode, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// smallZipSpoolLimit drops the in-memory spool threshold to 1 KiB for the
|
||||
// duration of a test. Crossing the real 32 MiB threshold needs buffers that
|
||||
// are too large for the 32-bit test binary's address space.
|
||||
func smallZipSpoolLimit(t *testing.T) {
|
||||
t.Helper()
|
||||
old := zipSpoolMemoryLimit
|
||||
zipSpoolMemoryLimit = 1024
|
||||
t.Cleanup(func() { zipSpoolMemoryLimit = old })
|
||||
}
|
||||
|
||||
func TestSpoolZipReaderMemory(t *testing.T) {
|
||||
data := []byte(strings.Repeat("x", 1024))
|
||||
ra, n, cleanup, err := SpoolZipReader(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("spool: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
if n != int64(len(data)) {
|
||||
t.Fatalf("size = %d, want %d", n, len(data))
|
||||
}
|
||||
if _, isFile := ra.(*os.File); isFile {
|
||||
t.Fatal("small archive should spool to memory")
|
||||
}
|
||||
buf := make([]byte, 4)
|
||||
if _, err := ra.ReadAt(buf, 0); err != nil || string(buf) != "xxxx" {
|
||||
t.Fatalf("ReadAt = %q, %v", buf, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpoolZipReaderTempFile(t *testing.T) {
|
||||
smallZipSpoolLimit(t)
|
||||
data := bytes.Repeat([]byte("y"), int(zipSpoolMemoryLimit)+1)
|
||||
ra, n, cleanup, err := SpoolZipReader(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("spool: %v", err)
|
||||
}
|
||||
f, isFile := ra.(*os.File)
|
||||
if !isFile {
|
||||
cleanup()
|
||||
t.Fatal("large archive should spool to a temp file")
|
||||
}
|
||||
name := f.Name()
|
||||
cleanup()
|
||||
if _, err := os.Stat(name); !os.IsNotExist(err) {
|
||||
t.Fatalf("temp file %s still exists after cleanup: %v", name, err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Fatalf("size = %d, want %d", n, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpoolZipReaderLimit(t *testing.T) {
|
||||
smallZipSpoolLimit(t)
|
||||
data := []byte(strings.Repeat("x", 2048))
|
||||
if _, _, _, err := SpoolZipReader(bytes.NewReader(data), 1024); err == nil {
|
||||
t.Fatal("expected limit error for in-memory archive")
|
||||
}
|
||||
if _, _, cleanup, err := SpoolZipReader(bytes.NewReader(bytes.Repeat([]byte("y"), int(zipSpoolMemoryLimit)+1024)), zipSpoolMemoryLimit+512); err == nil {
|
||||
t.Fatal("expected limit error for spooled archive")
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckZipLimits(t *testing.T) {
|
||||
over := &zip.Reader{File: make([]*zip.File, ZipExtractMaxMembers+1)}
|
||||
for i := range over.File {
|
||||
over.File[i] = &zip.File{}
|
||||
}
|
||||
if err := CheckZipLimits(over); err == nil {
|
||||
t.Fatal("expected member count limit error")
|
||||
}
|
||||
|
||||
big := &zip.Reader{File: []*zip.File{{}}}
|
||||
big.File[0].UncompressedSize64 = uint64(ZipExtractMaxBytes) + 1
|
||||
if err := CheckZipLimits(big); err == nil {
|
||||
t.Fatal("expected total size limit error")
|
||||
}
|
||||
|
||||
overflow := &zip.Reader{File: []*zip.File{{}, {}}}
|
||||
overflow.File[0].UncompressedSize64 = math.MaxUint64
|
||||
overflow.File[1].UncompressedSize64 = math.MaxUint64
|
||||
if err := CheckZipLimits(overflow); err == nil {
|
||||
t.Fatal("expected total size overflow error")
|
||||
}
|
||||
|
||||
ok := &zip.Reader{File: []*zip.File{{}, {}}}
|
||||
if err := CheckZipLimits(ok); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpoolZipReaderRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create("a/b.txt")
|
||||
w.Write([]byte("hello"))
|
||||
zw.Close()
|
||||
|
||||
ra, n, cleanup, err := SpoolZipReader(bytes.NewReader(buf.Bytes()), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("spool: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
zr, err := zip.NewReader(ra, n)
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
rc, err := zr.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open member: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, _ := io.ReadAll(rc)
|
||||
if string(data) != "hello" {
|
||||
t.Fatalf("member content = %q", data)
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -143,8 +143,14 @@ var baseS3ActionMap = map[string]string{
|
||||
"UploadPart": s3_constants.ACTION_WRITE,
|
||||
"CompleteMultipartUpload": s3_constants.ACTION_WRITE,
|
||||
"AbortMultipartUpload": s3_constants.ACTION_WRITE,
|
||||
"ListMultipartUploads": s3_constants.ACTION_WRITE,
|
||||
"ListParts": s3_constants.ACTION_WRITE,
|
||||
"ListMultipartUploads": s3_constants.ACTION_LIST_MULTIPART_UPLOADS,
|
||||
"ListParts": s3_constants.ACTION_LIST_PARTS,
|
||||
// AWS IAM names for the multipart listing operations get their own
|
||||
// action names so a policy grant cannot widen into a general Read
|
||||
// or Write grant; VerifyActionPermission matches them against the
|
||||
// resolved request action.
|
||||
"ListBucketMultipartUploads": s3_constants.ACTION_LIST_MULTIPART_UPLOADS,
|
||||
"ListMultipartUploadParts": s3_constants.ACTION_LIST_PARTS,
|
||||
// Retention and legal hold operations
|
||||
"GetObjectRetention": s3_constants.ACTION_GET_OBJECT_RETENTION,
|
||||
"PutObjectRetention": s3_constants.ACTION_PUT_OBJECT_RETENTION,
|
||||
@@ -209,6 +215,10 @@ func MapToIdentitiesAction(action string) string {
|
||||
return "PutBucketPolicy"
|
||||
case s3_constants.ACTION_DELETE_BUCKET_POLICY:
|
||||
return "DeleteBucketPolicy"
|
||||
case s3_constants.ACTION_LIST_MULTIPART_UPLOADS:
|
||||
return "ListBucketMultipartUploads"
|
||||
case s3_constants.ACTION_LIST_PARTS:
|
||||
return "ListMultipartUploadParts"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -563,8 +564,9 @@ func (f *FilerOIDCProviderStore) UpdateProvider(ctx context.Context, filerAddres
|
||||
func (f *FilerOIDCProviderStore) deleteProviderEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
|
||||
fullPath := f.basePath + "/" + entry.Name
|
||||
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
|
||||
LockKey: fullPath,
|
||||
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
|
||||
LockKey: fullPath,
|
||||
RequestId: uuid.NewString(),
|
||||
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
|
||||
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
|
||||
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
|
||||
}}},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/karlseguin/ccache/v2"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
@@ -380,8 +381,9 @@ func (f *FilerRoleStore) UpdateRole(ctx context.Context, filerAddress string, ro
|
||||
func (f *FilerRoleStore) deleteRoleEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
|
||||
fullPath := f.basePath + "/" + entry.Name
|
||||
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
|
||||
LockKey: fullPath,
|
||||
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
|
||||
LockKey: fullPath,
|
||||
RequestId: uuid.NewString(),
|
||||
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
|
||||
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
|
||||
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
|
||||
}}},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user