mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-18 12:35:52 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f291d973f4 | ||
|
|
760235c28c | ||
|
|
6b2b442450 | ||
|
|
a00eddb525 | ||
|
|
e24630251c | ||
|
|
0afc675a55 | ||
|
|
b868980260 | ||
|
|
5f19e3259f | ||
|
|
d9d6707401 | ||
|
|
8cde3d4486 | ||
|
|
47799a5b4f | ||
|
|
c9100a7213 | ||
|
|
1f2014568f | ||
|
|
a838661b83 | ||
|
|
c4d642b8aa | ||
|
|
5acb4578ab | ||
|
|
2f51a94416 | ||
|
|
6fc0489dd8 | ||
|
|
baae672b6f | ||
|
|
89ccb6d825 | ||
|
|
f48725a31d | ||
|
|
8056b702ba | ||
|
|
47ddf05d95 | ||
|
|
00ce1c6eba | ||
|
|
577a8459c9 | ||
|
|
34fe289f32 | ||
|
|
729df9c375 | ||
|
|
f3c5ba3cd6 | ||
|
|
3fe5a7d761 | ||
|
|
a6774f0e01 | ||
|
|
0443b66a75 | ||
|
|
0e570d6a8f | ||
|
|
146a090754 | ||
|
|
bfd0d5c084 | ||
|
|
92a76fc1a2 | ||
|
|
b665c329bc | ||
|
|
e4a77b8b16 | ||
|
|
013362d2d3 | ||
|
|
8ac4caf930 | ||
|
|
ab85f46529 | ||
|
|
5208c7c727 | ||
|
|
12b360f499 | ||
|
|
d1a631123f | ||
|
|
b799650357 | ||
|
|
2ff4a07544 | ||
|
|
1df6821ec6 |
@@ -116,6 +116,107 @@ jobs:
|
||||
echo "✓ COSI driver renders correctly"
|
||||
|
||||
echo ""
|
||||
echo "=== Testing long release name: service names match DNS references ==="
|
||||
# Use a release name that, combined with chart name "seaweedfs", exceeds 63 chars.
|
||||
# fullname = "my-very-long-release-name-that-will-cause-truncation-seaweedfs" (65 chars before trunc)
|
||||
LONG_RELEASE="my-very-long-release-name-that-will-cause-truncation"
|
||||
|
||||
# --- Normal mode: master + filer-client services vs helper-produced addresses ---
|
||||
helm template "$LONG_RELEASE" $CHART_DIR \
|
||||
--set s3.enabled=true \
|
||||
--set global.createBuckets[0].name=test > /tmp/longname.yaml
|
||||
|
||||
# Extract Service names from metadata
|
||||
MASTER_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname.yaml \
|
||||
| grep -- '-master$')
|
||||
FILER_CLIENT_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname.yaml \
|
||||
| grep -- '-filer-client$')
|
||||
|
||||
# Extract the hostname from WEED_CLUSTER_SW_MASTER in post-install-bucket-hook
|
||||
MASTER_ADDR=$(grep 'WEED_CLUSTER_SW_MASTER' -A1 /tmp/longname.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/')
|
||||
FILER_ADDR=$(grep 'WEED_CLUSTER_SW_FILER' -A1 /tmp/longname.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/')
|
||||
|
||||
# Extract the hostname from S3 deployment -filer= argument
|
||||
S3_FILER_HOST=$(grep '\-filer=' /tmp/longname.yaml \
|
||||
| head -1 | sed 's/.*-filer=\([^:]*\).*/\1/')
|
||||
|
||||
# The address helpers produce "<svc>.<namespace>:<port>"; extract just the svc name
|
||||
MASTER_ADDR_SVC=$(echo "$MASTER_ADDR" | cut -d. -f1)
|
||||
FILER_ADDR_SVC=$(echo "$FILER_ADDR" | cut -d. -f1)
|
||||
S3_FILER_SVC=$(echo "$S3_FILER_HOST" | cut -d. -f1)
|
||||
|
||||
echo " master Service.name: $MASTER_SVC"
|
||||
echo " cluster.masterAddress svc: $MASTER_ADDR_SVC"
|
||||
echo " filer-client Service.name: $FILER_CLIENT_SVC"
|
||||
echo " cluster.filerAddress svc: $FILER_ADDR_SVC"
|
||||
echo " S3 -filer= svc: $S3_FILER_SVC"
|
||||
|
||||
[ "$MASTER_SVC" = "$MASTER_ADDR_SVC" ] || { echo "FAIL: master service name mismatch"; exit 1; }
|
||||
[ "$FILER_CLIENT_SVC" = "$FILER_ADDR_SVC" ] || { echo "FAIL: filer-client service name mismatch"; exit 1; }
|
||||
[ "$FILER_CLIENT_SVC" = "$S3_FILER_SVC" ] || { echo "FAIL: S3 -filer= does not match filer-client service"; exit 1; }
|
||||
echo "✓ Normal mode: service names match DNS references with long release name"
|
||||
|
||||
# --- All-in-one mode: all-in-one service vs both helper addresses ---
|
||||
helm template "$LONG_RELEASE" $CHART_DIR \
|
||||
--set allInOne.enabled=true \
|
||||
--set global.createBuckets[0].name=test > /tmp/longname-aio.yaml
|
||||
|
||||
AIO_SVC=$(awk '/kind: Service/{found=1} found && /^ *name:/{print $2; found=0}' /tmp/longname-aio.yaml \
|
||||
| grep -- '-all-in-one$')
|
||||
AIO_MASTER_ADDR_SVC=$(grep 'WEED_CLUSTER_SW_MASTER' -A1 /tmp/longname-aio.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/' | cut -d. -f1)
|
||||
AIO_FILER_ADDR_SVC=$(grep 'WEED_CLUSTER_SW_FILER' -A1 /tmp/longname-aio.yaml \
|
||||
| grep 'value:' | head -1 | sed 's/.*value: *"\{0,1\}\([^":]*\).*/\1/' | cut -d. -f1)
|
||||
|
||||
echo " all-in-one Service.name: $AIO_SVC"
|
||||
echo " cluster.masterAddress svc: $AIO_MASTER_ADDR_SVC"
|
||||
echo " cluster.filerAddress svc: $AIO_FILER_ADDR_SVC"
|
||||
|
||||
[ "$AIO_SVC" = "$AIO_MASTER_ADDR_SVC" ] || { echo "FAIL: all-in-one master address mismatch"; exit 1; }
|
||||
[ "$AIO_SVC" = "$AIO_FILER_ADDR_SVC" ] || { echo "FAIL: all-in-one filer address mismatch"; exit 1; }
|
||||
echo "✓ All-in-one mode: service names match DNS references with long release name"
|
||||
|
||||
echo ""
|
||||
echo "=== Testing security+S3: no blank lines in shell command blocks ==="
|
||||
# Render the three manifests that include seaweedfs.s3.tlsArgs:
|
||||
# filer-statefulset, s3-deployment, all-in-one-deployment
|
||||
helm template test $CHART_DIR \
|
||||
--set global.enableSecurity=true \
|
||||
--set filer.s3.enabled=true \
|
||||
--set s3.enabled=true > /tmp/security-s3.yaml
|
||||
helm template test $CHART_DIR \
|
||||
--set global.enableSecurity=true \
|
||||
--set allInOne.enabled=true \
|
||||
--set allInOne.s3.enabled=true > /tmp/security-aio.yaml
|
||||
|
||||
pip install pyyaml -q
|
||||
python3 - /tmp/security-s3.yaml /tmp/security-aio.yaml <<'PYEOF'
|
||||
import yaml, sys
|
||||
errors = []
|
||||
for path in sys.argv[1:]:
|
||||
with open(path) as f:
|
||||
docs = list(yaml.safe_load_all(f))
|
||||
for doc in docs:
|
||||
if not doc or doc.get("kind") not in ("Deployment", "StatefulSet"):
|
||||
continue
|
||||
name = doc["metadata"]["name"]
|
||||
for c in doc["spec"]["template"]["spec"].get("containers", []):
|
||||
cmd = c.get("command", [])
|
||||
if len(cmd) >= 3 and cmd[0] == "/bin/sh" and cmd[1] == "-ec":
|
||||
script = cmd[2]
|
||||
for i, line in enumerate(script.splitlines(), 1):
|
||||
if line.strip() == "":
|
||||
errors.append(f"{path}: {name}/{c['name']} has blank line at script line {i}")
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f"FAIL: {e}", file=sys.stderr)
|
||||
print("Rendered with: global.enableSecurity=true, filer.s3.enabled=true, s3.enabled=true, allInOne.enabled=true", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("✓ No blank lines in security+S3 command blocks")
|
||||
PYEOF
|
||||
|
||||
echo "✅ All template rendering tests passed!"
|
||||
|
||||
- name: Create kind cluster
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# S3 Tables Maintenance Roadmap
|
||||
|
||||
## Context
|
||||
|
||||
SeaweedFS already ships an Iceberg maintenance worker with support for:
|
||||
|
||||
- data-file compaction
|
||||
- snapshot expiration
|
||||
- orphan-file removal
|
||||
- manifest rewriting
|
||||
- delete-aware compaction
|
||||
|
||||
This document captures the comparison against common open source table-maintenance systems and lays out the next execution phases after the current PR stack.
|
||||
|
||||
Reference docs used for comparison:
|
||||
|
||||
- [Apache Iceberg Spark procedures](https://iceberg.apache.org/docs/latest/spark-procedures/)
|
||||
- [Apache Iceberg Flink table maintenance](https://iceberg.apache.org/docs/nightly/flink-maintenance/)
|
||||
- [Delta Lake optimizations](https://docs.delta.io/latest/optimizations-oss.html)
|
||||
- [Delta Lake utility commands](https://docs.delta.io/latest/delta-utility.html)
|
||||
- [Apache Hudi cleaning](https://hudi.apache.org/docs/cleaning/)
|
||||
- [Apache Hudi clustering](https://hudi.apache.org/docs/clustering/)
|
||||
- [Apache Hudi metadata table](https://hudi.apache.org/docs/metadata/)
|
||||
- [Apache Amoro self-optimizing](https://amoro.apache.org/docs/latest/self-optimizing/)
|
||||
- [Apache Amoro optimizer groups](https://amoro.apache.org/docs/0.7.1/managing-optimizers/)
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Capability | Seaweed current + queued PRs | Iceberg OSS baseline | Delta/Hudi/Amoro baseline | Gap |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Atomic metadata commit | Covered by filer preconditions | Required by catalog commit protocol | Required | Closed in current stack |
|
||||
| Compaction on spec-evolved tables | Covered by planner fix | Supported | Supported | Closed in current stack |
|
||||
| Ref-aware snapshot expiration | Covered by lifecycle parity PR | Supported | Equivalent retention semantics exist | Closed in current stack |
|
||||
| Orphan dry-run | Covered by lifecycle parity PR | Supported by Iceberg procedures | Common | Closed in current stack |
|
||||
| Expired metadata cleanup | Covered by lifecycle parity PR | Supported by Iceberg procedures | Common | Closed in current stack |
|
||||
| Dedicated delete-file rewrite | Not implemented | `rewrite_position_delete_files` | Similar delete cleanup exists | Open |
|
||||
| Predicate-scoped maintenance | Not implemented | `where` support in rewrite procedures | Delta `OPTIMIZE ... WHERE` | Open |
|
||||
| Sort-aware layout rewrite | Not implemented | Iceberg supports sort-strategy rewrites | Delta Z-order, Hudi clustering | Open |
|
||||
| Fast planning/indexed orphan scan | Not implemented | Externalized in some deployments | Hudi metadata table, Amoro planning | Open |
|
||||
| Resource groups / quotas | Not implemented | Not core Iceberg | Amoro optimizer groups | Open |
|
||||
|
||||
## Current PR Stack
|
||||
|
||||
1. `codex/s3tables-maint-conditional-update`
|
||||
- add server-side `UpdateEntry` preconditions
|
||||
- use them for table metadata commits
|
||||
2. `codex/s3tables-maint-planner-multispec`
|
||||
- stop rejecting mixed-spec manifest lists during compaction planning
|
||||
3. `codex/s3tables-maint-lifecycle-parity`
|
||||
- make expiration ref-aware
|
||||
- add `clean_expired_metadata`
|
||||
- add `remove_orphans_dry_run`
|
||||
|
||||
## Remaining Parity Design
|
||||
|
||||
### 1. Dedicated Delete-File Rewrite
|
||||
|
||||
Goal: add an Iceberg-style `rewrite_position_delete_files` operation without changing the existing data compaction semantics.
|
||||
|
||||
#### API shape
|
||||
|
||||
- Add `rewrite_position_delete_files` to `operations`.
|
||||
- Add worker config:
|
||||
- `delete_target_file_size_mb`
|
||||
- `delete_min_input_files`
|
||||
- optional `delete_max_file_group_size_mb`
|
||||
|
||||
#### Planner
|
||||
|
||||
- Read current manifest list and collect delete manifests only.
|
||||
- Group delete files by:
|
||||
- partition spec ID
|
||||
- partition tuple
|
||||
- referenced data file path for position deletes
|
||||
- Schedule when a group has either:
|
||||
- at least `delete_min_input_files`, or
|
||||
- aggregate size below the target but spread across many small files
|
||||
|
||||
#### Executor
|
||||
|
||||
- Read position delete parquet files and concatenate rows into larger delete files.
|
||||
- Preserve equality delete files in phase 1 unless they share a compatible schema fingerprint.
|
||||
- Write new delete parquet files under `data/`.
|
||||
- Write replacement delete manifests under `metadata/`.
|
||||
- Commit by adding new delete manifests and removing the old ones in one metadata update.
|
||||
|
||||
#### Implementation constraint
|
||||
|
||||
`iceberg-go` currently hardcodes data-manifest content in `WriteManifest`, so SeaweedFS needs one of:
|
||||
|
||||
- a small upstream helper for delete-manifest writing, or
|
||||
- a local helper mirroring manifest serialization with explicit content selection
|
||||
|
||||
Recommended path: add a local helper first, then upstream it.
|
||||
|
||||
#### Tests
|
||||
|
||||
- rewrite multiple position delete files for the same target data file
|
||||
- mixed delete-manifest and data-manifest lists
|
||||
- retry cleanup after commit conflict
|
||||
- skip behavior when only one delete file exists
|
||||
|
||||
### 2. Predicate-Scoped Maintenance
|
||||
|
||||
Goal: add a narrow, safe `where` filter for rewrite-style maintenance operations.
|
||||
|
||||
#### Scope
|
||||
|
||||
Phase 1 should support partition-only predicates:
|
||||
|
||||
- `field = literal`
|
||||
- `field IN (...)`
|
||||
- conjunctions with `AND`
|
||||
|
||||
Do not support arbitrary row predicates in phase 1. That would require full data scans and can surprise operators.
|
||||
|
||||
#### API shape
|
||||
|
||||
- Add worker config `where`.
|
||||
- Apply it only to:
|
||||
- `compact`
|
||||
- `rewrite_manifests`
|
||||
- `rewrite_position_delete_files`
|
||||
- Reject `where` when combined with:
|
||||
- `expire_snapshots`
|
||||
- `remove_orphans`
|
||||
|
||||
#### Planner and executor rules
|
||||
|
||||
- Planner evaluates the predicate against manifest partition summaries first.
|
||||
- Executor re-validates each candidate file against the exact partition tuple.
|
||||
- If a table is unpartitioned and `where` is set, return a validation error.
|
||||
|
||||
#### Tests
|
||||
|
||||
- single-partition compaction
|
||||
- multi-spec tables with compatible partition field names
|
||||
- invalid predicates
|
||||
- executor-side revalidation when manifest summaries are coarse
|
||||
|
||||
## Extra Feature Design
|
||||
|
||||
### 1. Sort-Aware Rewrite
|
||||
|
||||
This is the strongest non-parity feature worth adding after the parity gaps.
|
||||
|
||||
#### Why it is worth doing
|
||||
|
||||
- SeaweedFS already rewrites data files and applies deletes.
|
||||
- Reordering rows during rewrite can improve scan pruning and compression.
|
||||
- It gives a Delta/Hudi-style clustering story without introducing a separate service.
|
||||
|
||||
#### Design
|
||||
|
||||
- Reuse compaction bins.
|
||||
- Add `rewrite_strategy` with values:
|
||||
- `binpack`
|
||||
- `sort`
|
||||
- For `sort`, default to the table sort order when present.
|
||||
- If no sort order exists, allow an explicit field list in config.
|
||||
|
||||
Phase 1 should stay partition-local and avoid global shuffles.
|
||||
|
||||
### 2. Indexed Planning
|
||||
|
||||
This is the highest-leverage scale feature once table counts grow.
|
||||
|
||||
#### Design
|
||||
|
||||
- Persist a compact maintenance index per table with:
|
||||
- latest metadata version
|
||||
- manifest counts
|
||||
- delete-file counts
|
||||
- last orphan scan watermark
|
||||
- Update it opportunistically during successful maintenance commits.
|
||||
- Use it to skip full directory walks when nothing relevant changed.
|
||||
|
||||
This is a better first step than a full Hudi-style metadata table.
|
||||
|
||||
### 3. Optimizer Groups and Quotas
|
||||
|
||||
This is useful when multiple maintenance types start competing for IO.
|
||||
|
||||
#### Design
|
||||
|
||||
- Introduce worker pools by operation class:
|
||||
- metadata-only
|
||||
- rewrite-light
|
||||
- rewrite-heavy
|
||||
- Add per-pool concurrency and byte-budget limits.
|
||||
- Keep scheduling local to the worker plugin before introducing a new control plane.
|
||||
|
||||
## Execution Plan
|
||||
|
||||
### Phase 0: Land the current stack
|
||||
|
||||
1. Merge commit-safety PR.
|
||||
2. Merge spec-evolved planner PR.
|
||||
3. Merge lifecycle parity PR.
|
||||
|
||||
### Phase 1: Finish Iceberg parity
|
||||
|
||||
1. Add `rewrite_position_delete_files`.
|
||||
2. Add partition-scoped `where` support for rewrite operations.
|
||||
3. Extend detection summaries and execution metrics for delete-file rewrite.
|
||||
|
||||
### Phase 2: Improve layout quality
|
||||
|
||||
1. Add sort-aware rewrite strategy.
|
||||
2. Add rewrite caps by bytes, file count, and partitions touched.
|
||||
3. Add richer admin reporting for before/after file counts and bytes.
|
||||
|
||||
### Phase 3: Improve scale and control
|
||||
|
||||
1. Add indexed planning to avoid repeated deep scans.
|
||||
2. Add resource groups and quotas.
|
||||
3. Revisit streaming output and schema-evolution support in the compaction engine if table sizes require it.
|
||||
|
||||
## Recommended PR Split For Remaining Work
|
||||
|
||||
1. `codex/s3tables-maint-delete-rewrite`
|
||||
- dedicated delete-file rewrite op
|
||||
- delete-manifest writer helper
|
||||
- planner and executor tests
|
||||
2. `codex/s3tables-maint-where-filter`
|
||||
- predicate parser
|
||||
- planner and executor validation
|
||||
- config/UI plumbing
|
||||
3. `codex/s3tables-maint-sort-strategy`
|
||||
- sort-aware rewrite strategy
|
||||
- metrics and safety limits
|
||||
4. `codex/s3tables-maint-planning-index`
|
||||
- incremental planning cache
|
||||
- scan fast-paths
|
||||
5. `codex/s3tables-maint-resource-groups`
|
||||
- worker pool limits
|
||||
- scheduler policy
|
||||
@@ -165,8 +165,9 @@ admin:
|
||||
enabled: true
|
||||
port: 23646
|
||||
grpcPort: 33646 # For worker connections
|
||||
adminUser: "admin"
|
||||
adminPassword: "your-secure-password" # Leave empty to disable auth
|
||||
secret:
|
||||
adminUser: "admin"
|
||||
adminPassword: "your-secure-password" # Leave empty to disable auth
|
||||
|
||||
# Optional: persist admin data
|
||||
data:
|
||||
@@ -191,6 +192,8 @@ If `adminPassword` is set, the admin interface requires authentication:
|
||||
|
||||
If `adminPassword` is empty or not set, the admin interface runs without authentication (not recommended for production).
|
||||
|
||||
As an alternative, a kubernetes Secret can be used (`admin.secret.existingSecret`).
|
||||
|
||||
### Admin Data Persistence
|
||||
|
||||
The admin component can store configuration and maintenance data. You can configure storage in several ways:
|
||||
|
||||
@@ -1539,7 +1539,7 @@
|
||||
"pluginVersion": "10.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$NAMESPACE\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$NAMESPACE\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1584,7 +1584,7 @@
|
||||
"pluginVersion": "10.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$NAMESPACE\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$NAMESPACE\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1679,7 +1679,7 @@
|
||||
"pluginVersion": "10.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$NAMESPACE\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$NAMESPACE\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
|
||||
@@ -93,12 +93,12 @@ spec:
|
||||
{{- $adminSecretName := .Values.admin.secret.existingSecret | default (printf "%s-admin-secret" (include "seaweedfs.fullname" .)) }}
|
||||
env:
|
||||
{{- if $adminAuthEnabled }}
|
||||
- name: SEAWEEDFS_ADMIN_USER
|
||||
- name: WEED_ADMIN_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $adminSecretName }}
|
||||
key: {{ if .Values.admin.secret.existingSecret }}{{ .Values.admin.secret.userKey }}{{ else }}adminUser{{ end }}
|
||||
- name: SEAWEEDFS_ADMIN_PASSWORD
|
||||
- name: WEED_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $adminSecretName }}
|
||||
@@ -153,10 +153,6 @@ spec:
|
||||
{{- else if .Values.admin.dataDir }}
|
||||
-dataDir={{ .Values.admin.dataDir }} \
|
||||
{{- end }}
|
||||
{{- if $adminAuthEnabled }}
|
||||
-adminUser="${SEAWEEDFS_ADMIN_USER}" \
|
||||
-adminPassword="${SEAWEEDFS_ADMIN_PASSWORD}" \
|
||||
{{- end }}
|
||||
{{- if .Values.admin.masters }}
|
||||
-masters={{ .Values.admin.masters }}{{- if .Values.admin.extraArgs }} \{{ end }}
|
||||
{{- else if .Values.global.masterServer }}
|
||||
|
||||
@@ -243,7 +243,7 @@ spec:
|
||||
{{- if $httpsPort }}
|
||||
-s3.port.https={{ $httpsPort }} \
|
||||
{{- end }}
|
||||
{{ include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth }}
|
||||
-s3.config=/etc/sw/s3/seaweedfs_s3_config \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-all-in-one" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "all-in-one") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-filer-client" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "filer-client") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -4,7 +4,7 @@ kind: Service
|
||||
metadata:
|
||||
annotations:
|
||||
service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
|
||||
name: {{ printf "%s-filer" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "filer") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -200,7 +200,7 @@ spec:
|
||||
{{- if .Values.filer.s3.httpsPort }}
|
||||
-s3.port.https={{ .Values.filer.s3.httpsPort }} \
|
||||
{{- end }}
|
||||
{{ include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "s3.") | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- if .Values.filer.s3.enableAuth }}
|
||||
-s3.config=/etc/sw/seaweedfs_s3_config \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-master" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "master") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -127,7 +127,7 @@ spec:
|
||||
{{- if .Values.s3.httpsPort }}
|
||||
-port.https={{ .Values.s3.httpsPort }} \
|
||||
{{- end }}
|
||||
{{ include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "") | nindent 14 }}
|
||||
{{- include "seaweedfs.s3.tlsArgs" (dict "root" . "prefix" "") | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- if .Values.s3.domainName }}
|
||||
-domainName={{ .Values.s3.domainName }} \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-s3" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "s3") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -49,7 +49,7 @@ spec:
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{ tpl .Values.sftp.topologySpreadConstraint . | nindent 8 | trim }}
|
||||
{{ tpl .Values.sftp.topologySpreadConstraints . | nindent 8 | trim }}
|
||||
{{- end }}
|
||||
{{- if .Values.sftp.tolerations }}
|
||||
tolerations:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-sftp" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "sftp") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -287,11 +287,8 @@ Compute the master service address to be used in cluster env vars.
|
||||
If allInOne is enabled, point to the all-in-one service; otherwise, point to the master service.
|
||||
*/}}
|
||||
{{- define "seaweedfs.cluster.masterAddress" -}}
|
||||
{{- $serviceNameSuffix := "-master" -}}
|
||||
{{- if .Values.allInOne.enabled -}}
|
||||
{{- $serviceNameSuffix = "-all-in-one" -}}
|
||||
{{- end -}}
|
||||
{{- printf "%s.%s:%d" (printf "%s%s" (include "seaweedfs.fullname" .) $serviceNameSuffix | trunc 63 | trimSuffix "-") .Release.Namespace (int .Values.master.port) -}}
|
||||
{{- $component := ternary "all-in-one" "master" .Values.allInOne.enabled -}}
|
||||
{{- printf "%s.%s:%d" (include "seaweedfs.componentName" (list . $component)) .Release.Namespace (int .Values.master.port) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
@@ -299,11 +296,8 @@ Compute the filer service address to be used in cluster env vars.
|
||||
If allInOne is enabled, point to the all-in-one service; otherwise, point to the filer-client service.
|
||||
*/}}
|
||||
{{- define "seaweedfs.cluster.filerAddress" -}}
|
||||
{{- $serviceNameSuffix := "-filer-client" -}}
|
||||
{{- if .Values.allInOne.enabled -}}
|
||||
{{- $serviceNameSuffix = "-all-in-one" -}}
|
||||
{{- end -}}
|
||||
{{- printf "%s.%s:%d" (printf "%s%s" (include "seaweedfs.fullname" .) $serviceNameSuffix | trunc 63 | trimSuffix "-") .Release.Namespace (int .Values.filer.port) -}}
|
||||
{{- $component := ternary "all-in-one" "filer-client" .Values.allInOne.enabled -}}
|
||||
{{- printf "%s.%s:%d" (include "seaweedfs.componentName" (list . $component)) .Release.Namespace (int .Values.filer.port) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-%s" (include "seaweedfs.fullname" $) $volumeName | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list $ $volumeName) }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" $ }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ printf "%s-worker" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
name: {{ include "seaweedfs.componentName" (list . "worker") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -547,7 +547,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -649,7 +649,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -731,7 +731,7 @@
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
@@ -1182,7 +1182,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1275,7 +1275,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -1368,7 +1368,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -3309,4 +3309,4 @@
|
||||
"title": "SeaweedFS",
|
||||
"uid": "nh02dOVnz",
|
||||
"version": 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +754,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -835,7 +835,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -914,7 +914,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_received_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -403,7 +403,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_bucket_traffic_sent_bytes_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
@@ -496,7 +496,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__interval])) by (bucket)",
|
||||
"expr": "sum(rate(SeaweedFS_s3_request_total{namespace=\"$namespace\",service=~\"$service-api\"}[$__rate_interval])) by (bucket)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"intervalFactor": 2,
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
v1credentials "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v1signer "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
v1s3 "github.com/aws/aws-sdk-go/service/s3"
|
||||
v2aws "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
v2s3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
v1credentials "github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v1signer "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
v1s3 "github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -187,8 +187,8 @@ func testGetObjectAttributesMultipart(t *testing.T, cluster *TestCluster) {
|
||||
|
||||
// Test pagination: MaxParts=1
|
||||
resp2, err := client.GetObjectAttributes(context.Background(), &v2s3.GetObjectAttributesInput{
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
Bucket: v2aws.String(bucketName),
|
||||
Key: v2aws.String(objectKey),
|
||||
MaxParts: v2aws.Int32(1),
|
||||
ObjectAttributes: []types.ObjectAttributes{
|
||||
types.ObjectAttributesObjectParts,
|
||||
|
||||
@@ -0,0 +1,987 @@
|
||||
// Package maintenance contains integration tests for the iceberg table
|
||||
// maintenance plugin worker. Tests start a real weed mini cluster, create
|
||||
// tables via the S3 Tables API, populate Iceberg metadata via the filer
|
||||
// gRPC API, and then exercise the iceberg.Handler operations against the
|
||||
// live filer.
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/command"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
icebergHandler "github.com/seaweedfs/seaweedfs/weed/plugin/worker/iceberg"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cluster lifecycle (mirrors test/s3tables/table-buckets pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type testCluster struct {
|
||||
dataDir string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
filerGrpcPort int
|
||||
s3Port int
|
||||
s3Endpoint string
|
||||
isRunning bool
|
||||
}
|
||||
|
||||
var shared *testCluster
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
testDir, err := os.MkdirTemp("", "seaweed-iceberg-maint-*")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: failed to create temp dir: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cluster, err := startCluster(testDir, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: failed to start cluster: %v\n", err)
|
||||
os.RemoveAll(testDir)
|
||||
os.Exit(0)
|
||||
}
|
||||
shared = cluster
|
||||
|
||||
code := m.Run()
|
||||
shared.stop()
|
||||
os.RemoveAll(testDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func startCluster(testDir string, extraArgs []string) (*testCluster, error) {
|
||||
ports, err := findPorts(10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
masterPort, masterGrpc := ports[0], ports[1]
|
||||
volumePort, volumeGrpc := ports[2], ports[3]
|
||||
filerPort, filerGrpc := ports[4], ports[5]
|
||||
s3Port, s3Grpc := ports[6], ports[7]
|
||||
adminPort, adminGrpc := ports[8], ports[9]
|
||||
|
||||
_ = os.Remove(filepath.Join(testDir, "mini.options"))
|
||||
|
||||
// Empty security.toml disables JWT auth.
|
||||
if err := os.WriteFile(filepath.Join(testDir, "security.toml"), []byte("# test\n"), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
|
||||
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
|
||||
}
|
||||
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
|
||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c := &testCluster{
|
||||
dataDir: testDir,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
filerGrpcPort: filerGrpc,
|
||||
s3Port: s3Port,
|
||||
s3Endpoint: fmt.Sprintf("http://127.0.0.1:%d", s3Port),
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
oldDir, _ := os.Getwd()
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Chdir(oldDir); os.Args = oldArgs }()
|
||||
os.Chdir(testDir)
|
||||
|
||||
args := []string{
|
||||
"-dir=" + testDir,
|
||||
"-master.dir=" + testDir,
|
||||
"-master.port=" + strconv.Itoa(masterPort),
|
||||
"-master.port.grpc=" + strconv.Itoa(masterGrpc),
|
||||
"-volume.port=" + strconv.Itoa(volumePort),
|
||||
"-volume.port.grpc=" + strconv.Itoa(volumeGrpc),
|
||||
"-volume.port.public=" + strconv.Itoa(volumePort),
|
||||
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
|
||||
"-filer.port=" + strconv.Itoa(filerPort),
|
||||
"-filer.port.grpc=" + strconv.Itoa(filerGrpc),
|
||||
"-s3.port=" + strconv.Itoa(s3Port),
|
||||
"-s3.port.grpc=" + strconv.Itoa(s3Grpc),
|
||||
"-admin.port=" + strconv.Itoa(adminPort),
|
||||
"-admin.port.grpc=" + strconv.Itoa(adminGrpc),
|
||||
"-webdav.port=0",
|
||||
"-admin.ui=false",
|
||||
"-master.volumeSizeLimitMB=32",
|
||||
"-ip=127.0.0.1",
|
||||
"-master.peers=none",
|
||||
"-s3.iam.readOnly=false",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
os.Args = append([]string{"weed"}, args...)
|
||||
glog.MaxSize = 1024 * 1024
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" && cmd.Run != nil {
|
||||
cmd.Flag.Parse(os.Args[1:])
|
||||
command.MiniClusterCtx = ctx
|
||||
cmd.Run(cmd, cmd.Flag.Args())
|
||||
command.MiniClusterCtx = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := waitReady(c.s3Endpoint, 30*time.Second); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
c.isRunning = true
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *testCluster) stop() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
if c.isRunning {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { c.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testCluster) filerConn(t *testing.T) (*grpc.ClientConn, filer_pb.SeaweedFilerClient) {
|
||||
t.Helper()
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort)
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn, filer_pb.NewSeaweedFilerClient(conn)
|
||||
}
|
||||
|
||||
func findPorts(n int) ([]int, error) {
|
||||
ls := make([]*net.TCPListener, n)
|
||||
ps := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
for j := 0; j < i; j++ {
|
||||
ls[j].Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ls[i] = l.(*net.TCPListener)
|
||||
ps[i] = ls[i].Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
for _, l := range ls {
|
||||
l.Close()
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func waitReady(endpoint string, timeout time.Duration) error {
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(endpoint)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for %s", endpoint)
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for populating Iceberg table state via filer gRPC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestSchema() *iceberg.Schema {
|
||||
return iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
|
||||
iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
|
||||
iceberg.NestedField{ID: 3, Type: iceberg.PrimitiveTypes.String, Name: "payload", Required: false},
|
||||
)
|
||||
}
|
||||
|
||||
type compactRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
Payload string `parquet:"payload"`
|
||||
}
|
||||
|
||||
func buildParquetData(t *testing.T, prefix string, rowCount int) []byte {
|
||||
t.Helper()
|
||||
|
||||
rows := make([]compactRow, rowCount)
|
||||
for i := range rows {
|
||||
rows[i] = compactRow{
|
||||
ID: int64(i + 1),
|
||||
Name: fmt.Sprintf("%s-%06d", prefix, i),
|
||||
Payload: fmt.Sprintf("%s-%06d-%s", prefix, i, strings.Repeat(fmt.Sprintf("%04d", i%10000), 128)),
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewGenericWriter[compactRow](&buf)
|
||||
_, err := writer.Write(rows)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func buildMetadata(t *testing.T, snapshots []table.Snapshot) table.Metadata {
|
||||
t.Helper()
|
||||
schema := newTestSchema()
|
||||
meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, "s3://test/table", nil)
|
||||
require.NoError(t, err)
|
||||
if len(snapshots) == 0 {
|
||||
return meta
|
||||
}
|
||||
|
||||
// Iceberg validates that snapshot timestamps >= metadata lastUpdatedMs.
|
||||
// Offset all timestamps so they're safely after the metadata was created.
|
||||
baseMs := time.Now().UnixMilli() + 100
|
||||
for i := range snapshots {
|
||||
snapshots[i].TimestampMs = baseMs + int64(i)
|
||||
}
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(meta, "s3://test/table")
|
||||
require.NoError(t, err)
|
||||
var lastID int64
|
||||
for i := range snapshots {
|
||||
s := snapshots[i]
|
||||
require.NoError(t, builder.AddSnapshot(&s))
|
||||
lastID = s.SnapshotID
|
||||
}
|
||||
require.NoError(t, builder.SetSnapshotRef(table.MainBranch, lastID, table.BranchRef))
|
||||
result, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
// s3put performs an S3-signed PUT request. When key is empty it creates a
|
||||
// bucket; otherwise it uploads an object.
|
||||
func s3put(t *testing.T, s3Endpoint, bucket, key string, body []byte) {
|
||||
t.Helper()
|
||||
urlPath := "/" + bucket
|
||||
if key != "" {
|
||||
urlPath += "/" + key
|
||||
}
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, s3Endpoint+urlPath, reader)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
hash := sha256.Sum256(body)
|
||||
err = v4.NewSigner().SignHTTP(context.Background(),
|
||||
aws.Credentials{AccessKeyID: "admin", SecretAccessKey: "admin"},
|
||||
req, hex.EncodeToString(hash[:]), "s3", "us-east-1", time.Now())
|
||||
require.NoError(t, err)
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
require.FailNowf(t, "s3put failed", "s3put(%s/%s) → %d: %s", bucket, key, resp.StatusCode, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func newS3Client(t *testing.T, endpoint string) *s3.Client {
|
||||
t.Helper()
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
||||
config.WithRegion("us-east-1"),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("admin", "admin", "")),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
|
||||
func s3putObject(t *testing.T, client *s3.Client, bucket, key string, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type testFilerClient struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (c testFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c.client)
|
||||
}
|
||||
|
||||
func (c testFilerClient) AdjustedUrl(location *filer_pb.Location) string {
|
||||
if location == nil {
|
||||
return ""
|
||||
}
|
||||
if location.PublicUrl != "" {
|
||||
return location.PublicUrl
|
||||
}
|
||||
return location.Url
|
||||
}
|
||||
|
||||
func (c testFilerClient) GetDataCenter() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, client filer_pb.SeaweedFilerClient, dir, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
entry := lookupEntry(t, client, dir, name)
|
||||
require.NotNil(t, entry, "readFile(%s, %s): entry missing", dir, name)
|
||||
if len(entry.Content) > 0 || len(entry.Chunks) == 0 {
|
||||
return entry.Content
|
||||
}
|
||||
|
||||
reader := filer.NewFileReader(testFilerClient{client: client}, entry)
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
content, err := io.ReadAll(reader)
|
||||
require.NoError(t, err, "readFile(%s, %s): read chunked content", dir, name)
|
||||
return content
|
||||
}
|
||||
|
||||
// createBucketViaS3 creates a regular S3 bucket via the S3 PUT Bucket API.
|
||||
// populateTableViaFiler creates the Iceberg directory structure, metadata xattr,
|
||||
// manifests, and data files for a table directly in the filer.
|
||||
// The table bucket, namespace, and table must already exist (created via S3
|
||||
// Tables API).
|
||||
func populateTableViaFiler(
|
||||
t *testing.T,
|
||||
client filer_pb.SeaweedFilerClient,
|
||||
s3Endpoint, bucketName, namespace, tableName string,
|
||||
snapshots []table.Snapshot,
|
||||
) table.Metadata {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
meta := buildMetadata(t, snapshots)
|
||||
fullJSON, err := json.Marshal(meta)
|
||||
require.NoError(t, err)
|
||||
|
||||
tablePath := path.Join(namespace, tableName)
|
||||
bucketsPath := s3tables.TablesPath
|
||||
tableFilerPath := path.Join(bucketsPath, bucketName, tablePath)
|
||||
metaDir := path.Join(tableFilerPath, "metadata")
|
||||
dataDir := path.Join(tableFilerPath, "data")
|
||||
|
||||
// Build the table metadata xattr
|
||||
internalMeta := map[string]interface{}{
|
||||
"metadataVersion": 1,
|
||||
"metadataLocation": "metadata/v1.metadata.json",
|
||||
"metadata": map[string]interface{}{
|
||||
"fullMetadata": json.RawMessage(fullJSON),
|
||||
},
|
||||
}
|
||||
xattr, err := json.Marshal(internalMeta)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create the S3 bucket via PUT, then create the directory tree via S3 PUT
|
||||
// object with zero-byte content (the filer creates intermediate dirs).
|
||||
s3put(t, s3Endpoint, bucketName, "", nil) // create bucket
|
||||
s3put(t, s3Endpoint, bucketName, namespace+"/.dir", []byte{}) // create ns dir
|
||||
s3put(t, s3Endpoint, bucketName, tablePath+"/.dir", []byte{}) // create table dir
|
||||
s3put(t, s3Endpoint, bucketName, tablePath+"/metadata/.dir", []byte{}) // create metadata dir
|
||||
s3put(t, s3Endpoint, bucketName, tablePath+"/data/.dir", []byte{}) // create data dir
|
||||
|
||||
// Now set the table bucket and table xattrs via filer gRPC.
|
||||
// Mark bucket as table bucket.
|
||||
bucketEntry := lookupEntry(t, client, bucketsPath, bucketName)
|
||||
require.NotNil(t, bucketEntry, "bucket should exist after S3 PUT")
|
||||
if bucketEntry.Extended == nil {
|
||||
bucketEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
bucketEntry.Extended[s3tables.ExtendedKeyTableBucket] = []byte("true")
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: bucketsPath, Entry: bucketEntry,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set table metadata xattr.
|
||||
nsDir := path.Join(bucketsPath, bucketName, namespace)
|
||||
tableEntry := lookupEntry(t, client, nsDir, tableName)
|
||||
require.NotNil(t, tableEntry, "table dir should exist after S3 PUT")
|
||||
if tableEntry.Extended == nil {
|
||||
tableEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
tableEntry.Extended[s3tables.ExtendedKeyMetadata] = xattr
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: nsDir, Entry: tableEntry,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write manifest + manifest list + data file for each snapshot
|
||||
schema := meta.CurrentSchema()
|
||||
spec := meta.PartitionSpec()
|
||||
version := meta.Version()
|
||||
|
||||
for _, snap := range snapshots {
|
||||
if snap.ManifestList == "" {
|
||||
continue
|
||||
}
|
||||
snapID := snap.SnapshotID
|
||||
dataFileName := fmt.Sprintf("snap-%d-data.parquet", snapID)
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec, iceberg.EntryContentData,
|
||||
path.Join("data", dataFileName), iceberg.ParquetFile,
|
||||
map[int]any{}, nil, nil, 10, 4096,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build(),
|
||||
)
|
||||
|
||||
// Manifest
|
||||
manifestName := fmt.Sprintf("manifest-%d.avro", snapID)
|
||||
var manifestBuf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(
|
||||
path.Join("metadata", manifestName), &manifestBuf,
|
||||
version, spec, schema, snapID, []iceberg.ManifestEntry{entry},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
writeFile(t, ctx, client, metaDir, manifestName, manifestBuf.Bytes())
|
||||
|
||||
// Manifest list
|
||||
mlName := path.Base(snap.ManifestList)
|
||||
var mlBuf bytes.Buffer
|
||||
parent := snap.ParentSnapshotID
|
||||
seqNum := snap.SequenceNumber
|
||||
require.NoError(t, iceberg.WriteManifestList(
|
||||
version, &mlBuf, snapID, parent, &seqNum, 0,
|
||||
[]iceberg.ManifestFile{mf},
|
||||
))
|
||||
writeFile(t, ctx, client, metaDir, mlName, mlBuf.Bytes())
|
||||
|
||||
// Data file (dummy content)
|
||||
writeFile(t, ctx, client, dataDir, dataFileName, []byte("fake-parquet"))
|
||||
}
|
||||
|
||||
// Wait for snapshot timestamps (set slightly in the future by
|
||||
// buildMetadata) to move into the past so expiration checks work.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
|
||||
t.Helper()
|
||||
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(), Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0644), FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "writeFile(%s, %s): rpc error", dir, name)
|
||||
require.Empty(t, resp.Error, "writeFile(%s, %s): resp error", dir, name)
|
||||
}
|
||||
|
||||
func upsertFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
|
||||
t.Helper()
|
||||
|
||||
entry := lookupEntry(t, client, dir, name)
|
||||
if entry == nil {
|
||||
writeFile(t, ctx, client, dir, name, content)
|
||||
return
|
||||
}
|
||||
if entry.Attributes == nil {
|
||||
entry.Attributes = &filer_pb.FuseAttributes{}
|
||||
}
|
||||
entry.Content = content
|
||||
entry.Chunks = nil
|
||||
entry.Attributes.Mtime = time.Now().Unix()
|
||||
entry.Attributes.FileMode = uint32(0644)
|
||||
entry.Attributes.FileSize = uint64(len(content))
|
||||
resp, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: entry,
|
||||
})
|
||||
require.NoError(t, err, "upsertFile(%s, %s): rpc error", dir, name)
|
||||
require.NotNil(t, resp, "upsertFile(%s, %s): nil response", dir, name)
|
||||
}
|
||||
|
||||
func lookupEntry(t *testing.T, client filer_pb.SeaweedFilerClient, dir, name string) *filer_pb.Entry {
|
||||
t.Helper()
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir, Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
require.NoError(t, err, "lookupEntry(%s, %s): unexpected error", dir, name)
|
||||
}
|
||||
return resp.Entry
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIcebergMaintenanceIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
t.Run("ExpireSnapshots", testExpireSnapshots)
|
||||
t.Run("CompactDataFiles", testCompactDataFiles)
|
||||
t.Run("RemoveOrphans", testRemoveOrphans)
|
||||
t.Run("RewriteManifests", testRewriteManifests)
|
||||
t.Run("FullMaintenanceCycle", testFullMaintenanceCycle)
|
||||
}
|
||||
|
||||
func testExpireSnapshots(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-expire-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
SnapshotRetentionHours: 0, // instant expiry — everything eligible
|
||||
MaxSnapshotsToKeep: 1, // keep only the current snapshot
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, _, err := handler.ExpireSnapshots(context.Background(), client, bucket, path.Join(ns, tbl), config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "expired")
|
||||
t.Logf("ExpireSnapshots result: %s", result)
|
||||
|
||||
// Verify metadata was updated
|
||||
entry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
|
||||
require.NotNil(t, entry)
|
||||
xattr := entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
require.NotEmpty(t, xattr)
|
||||
|
||||
// Parse updated metadata to verify snapshot count
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
|
||||
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should have been incremented")
|
||||
}
|
||||
|
||||
func testCompactDataFiles(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-compact-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro", SequenceNumber: 1},
|
||||
}
|
||||
meta := populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
ctx := context.Background()
|
||||
tablePath := path.Join(ns, tbl)
|
||||
dataDir := path.Join(s3tables.TablesPath, bucket, tablePath, "data")
|
||||
metaDir := path.Join(s3tables.TablesPath, bucket, tablePath, "metadata")
|
||||
|
||||
type parquetFile struct {
|
||||
name string
|
||||
recordCount int64
|
||||
fileSize int64
|
||||
}
|
||||
|
||||
s3Client := newS3Client(t, shared.s3Endpoint)
|
||||
var files []parquetFile
|
||||
for i := 1; i <= 3; i++ {
|
||||
name := fmt.Sprintf("compact-input-%d.parquet", i)
|
||||
content := buildParquetData(t, fmt.Sprintf("compact-%d", i), 2000)
|
||||
s3putObject(t, s3Client, bucket, path.Join(tablePath, "data", name), content)
|
||||
|
||||
entry := lookupEntry(t, client, dataDir, name)
|
||||
require.NotNil(t, entry, "uploaded parquet file should exist")
|
||||
require.NotEmpty(t, entry.Chunks, "uploaded parquet file should be chunk-backed")
|
||||
|
||||
files = append(files, parquetFile{
|
||||
name: name,
|
||||
recordCount: 2000,
|
||||
fileSize: int64(len(content)),
|
||||
})
|
||||
}
|
||||
|
||||
schema := meta.CurrentSchema()
|
||||
spec := meta.PartitionSpec()
|
||||
version := meta.Version()
|
||||
snapID := snapshots[0].SnapshotID
|
||||
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(files))
|
||||
for _, file := range files {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
path.Join("data", file.name),
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
file.recordCount,
|
||||
file.fileSize,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
entries = append(entries, iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build(),
|
||||
))
|
||||
}
|
||||
|
||||
manifestName := "manifest-compact.avro"
|
||||
var manifestBuf bytes.Buffer
|
||||
manifest, err := iceberg.WriteManifest(
|
||||
path.Join("metadata", manifestName), &manifestBuf,
|
||||
version, spec, schema, snapID, entries,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
writeFile(t, ctx, client, metaDir, manifestName, manifestBuf.Bytes())
|
||||
|
||||
var manifestListBuf bytes.Buffer
|
||||
seqNum := snapshots[0].SequenceNumber
|
||||
require.NoError(t, iceberg.WriteManifestList(
|
||||
version, &manifestListBuf, snapID, snapshots[0].ParentSnapshotID, &seqNum, 0,
|
||||
[]iceberg.ManifestFile{manifest},
|
||||
))
|
||||
upsertFile(t, ctx, client, metaDir, path.Base(snapshots[0].ManifestList), manifestListBuf.Bytes())
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
TargetFileSizeBytes: 16 * 1024 * 1024,
|
||||
MinInputFiles: 2,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, metrics, err := handler.CompactDataFiles(ctx, client, bucket, tablePath, config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "compacted")
|
||||
require.NotNil(t, metrics, "expected non-nil metrics from CompactDataFiles")
|
||||
assert.Contains(t, metrics, icebergHandler.MetricFilesMerged)
|
||||
assert.Contains(t, metrics, icebergHandler.MetricFilesWritten)
|
||||
assert.Contains(t, metrics, icebergHandler.MetricBins)
|
||||
assert.Contains(t, metrics, icebergHandler.MetricDurationMs)
|
||||
t.Logf("CompactDataFiles result: %s, metrics: %v", result, metrics)
|
||||
|
||||
var compacted *filer_pb.Entry
|
||||
listErr := filer_pb.SeaweedList(ctx, client, dataDir, "", func(entry *filer_pb.Entry, isLast bool) error {
|
||||
// Compacted output uses compact-{snapID}-{newSnapID}-{binIdx}.parquet.
|
||||
// The test inputs use compact-input-{n}.parquet and must not satisfy this check.
|
||||
if strings.HasPrefix(entry.Name, "compact-") &&
|
||||
!strings.HasPrefix(entry.Name, "compact-input-") &&
|
||||
strings.HasSuffix(entry.Name, ".parquet") {
|
||||
compacted = entry
|
||||
}
|
||||
return nil
|
||||
}, "", false, 1000)
|
||||
require.NoError(t, listErr)
|
||||
require.NotNil(t, compacted, "compaction should create a merged parquet file")
|
||||
|
||||
tableEntry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
|
||||
require.NotNil(t, tableEntry)
|
||||
xattr := tableEntry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
require.NotEmpty(t, xattr)
|
||||
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
MetadataLocation string `json:"metadataLocation,omitempty"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
|
||||
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should advance after compaction")
|
||||
|
||||
metadataName := path.Base(internalMeta.MetadataLocation)
|
||||
require.NotEmpty(t, metadataName, "compaction should point table metadata at a committed metadata file")
|
||||
metadataBytes := readFile(t, client, metaDir, metadataName)
|
||||
committedMeta, err := table.ParseMetadataBytes(metadataBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
currentSnap := committedMeta.CurrentSnapshot()
|
||||
require.NotNil(t, currentSnap, "committed metadata should include a current snapshot")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, currentSnap.SequenceNumber, "compaction should advance the snapshot sequence number")
|
||||
|
||||
manifestListName := path.Base(currentSnap.ManifestList)
|
||||
require.NotEmpty(t, manifestListName, "committed snapshot should reference a manifest list")
|
||||
manifestListBytes := readFile(t, client, metaDir, manifestListName)
|
||||
manifestFiles, err := iceberg.ReadManifestList(bytes.NewReader(manifestListBytes))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, manifestFiles, 1, "compaction should commit a single replacement manifest")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, manifestFiles[0].SequenceNum(), "new manifest should use the committed snapshot sequence number")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, manifestFiles[0].MinSequenceNum(), "compaction manifest should inherit the committed sequence number for added data")
|
||||
|
||||
manifestName = path.Base(manifestFiles[0].FilePath())
|
||||
manifestBytes := readFile(t, client, metaDir, manifestName)
|
||||
manifestEntries, err := iceberg.ReadManifest(manifestFiles[0], bytes.NewReader(manifestBytes), false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, manifestEntries, len(files)+1, "compaction should replace the input files with one merged output")
|
||||
|
||||
deletedPaths := make(map[string]struct{}, len(files))
|
||||
addedPaths := make(map[string]struct{})
|
||||
for _, entry := range manifestEntries {
|
||||
switch entry.Status() {
|
||||
case iceberg.EntryStatusADDED:
|
||||
addedPaths[entry.DataFile().FilePath()] = struct{}{}
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, entry.SequenceNum(), "added entries should inherit the new snapshot sequence number")
|
||||
fileSeqNum := entry.FileSequenceNum()
|
||||
require.NotNil(t, fileSeqNum, "added entries should carry a file sequence number")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber+1, *fileSeqNum)
|
||||
case iceberg.EntryStatusDELETED:
|
||||
deletedPaths[entry.DataFile().FilePath()] = struct{}{}
|
||||
assert.Equal(t, snapshots[0].SequenceNumber, entry.SequenceNum(), "deleted entries should preserve the original data sequence number")
|
||||
fileSeqNum := entry.FileSequenceNum()
|
||||
require.NotNil(t, fileSeqNum, "deleted entries should preserve file sequence numbers")
|
||||
assert.Equal(t, snapshots[0].SequenceNumber, *fileSeqNum)
|
||||
default:
|
||||
t.Fatalf("unexpected manifest entry status %v for %s", entry.Status(), entry.DataFile().FilePath())
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, addedPaths, 1, "compaction should add exactly one merged parquet file")
|
||||
assert.Contains(t, addedPaths, path.Join("data", compacted.Name))
|
||||
require.Len(t, deletedPaths, len(files), "compaction should delete every original small input file")
|
||||
for _, file := range files {
|
||||
assert.Contains(t, deletedPaths, path.Join("data", file.name))
|
||||
}
|
||||
}
|
||||
|
||||
func testRemoveOrphans(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-orphan-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
ctx := context.Background()
|
||||
tablePath := path.Join(ns, tbl)
|
||||
tableFilerPath := path.Join(s3tables.TablesPath, bucket, tablePath)
|
||||
dataDir := path.Join(tableFilerPath, "data")
|
||||
metaDir := path.Join(tableFilerPath, "metadata")
|
||||
|
||||
// Create orphan files (old enough to be removed)
|
||||
oldTime := time.Now().Add(-200 * time.Hour).Unix()
|
||||
writeOrphan := func(dir, name string) {
|
||||
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: oldTime, Crtime: oldTime,
|
||||
FileMode: uint32(0644), FileSize: 100,
|
||||
},
|
||||
Content: []byte("orphan"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "writeOrphan(%s, %s): rpc error", dir, name)
|
||||
require.Empty(t, resp.Error, "writeOrphan(%s, %s): resp error", dir, name)
|
||||
}
|
||||
writeOrphan(dataDir, "orphan-data.parquet")
|
||||
writeOrphan(metaDir, "orphan-meta.avro")
|
||||
|
||||
// Create a recent orphan (should NOT be removed)
|
||||
writeFile(t, ctx, client, dataDir, "recent-orphan.parquet", []byte("new"))
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
OrphanOlderThanHours: 72,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, _, err := handler.RemoveOrphans(ctx, client, bucket, tablePath, config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "removed")
|
||||
t.Logf("RemoveOrphans result: %s", result)
|
||||
|
||||
// Verify orphans were removed
|
||||
assert.Nil(t, lookupEntry(t, client, dataDir, "orphan-data.parquet"), "old orphan data file should be deleted")
|
||||
assert.Nil(t, lookupEntry(t, client, metaDir, "orphan-meta.avro"), "old orphan meta file should be deleted")
|
||||
|
||||
// Verify recent orphan was kept
|
||||
assert.NotNil(t, lookupEntry(t, client, dataDir, "recent-orphan.parquet"), "recent orphan should be kept")
|
||||
}
|
||||
|
||||
func testRewriteManifests(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-rewrite-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
// Each snapshot gets its own manifest list with one manifest. The current
|
||||
// snapshot (latest) therefore has only 1 manifest — below the rewrite
|
||||
// threshold. This tests the threshold check against a real filer.
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
config := icebergHandler.Config{
|
||||
MinManifestsToRewrite: 5, // threshold higher than 1 manifest
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
tablePath := path.Join(ns, tbl)
|
||||
result, _, err := handler.RewriteManifests(context.Background(), client, bucket, tablePath, config)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "below threshold")
|
||||
t.Logf("RewriteManifests result: %s", result)
|
||||
}
|
||||
|
||||
func testFullMaintenanceCycle(t *testing.T) {
|
||||
_, client := shared.filerConn(t)
|
||||
suffix := randomSuffix()
|
||||
bucket := "maint-full-" + suffix
|
||||
ns := "ns"
|
||||
tbl := "tbl"
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
}
|
||||
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
|
||||
|
||||
ctx := context.Background()
|
||||
tablePath := path.Join(ns, tbl)
|
||||
|
||||
// Add an orphan
|
||||
dataDir := path.Join(s3tables.TablesPath, bucket, tablePath, "data")
|
||||
oldTime := time.Now().Add(-200 * time.Hour).Unix()
|
||||
orphanResp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dataDir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "orphan.parquet",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: oldTime, Crtime: oldTime,
|
||||
FileMode: uint32(0644), FileSize: 100,
|
||||
},
|
||||
Content: []byte("orphan"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, orphanResp.Error)
|
||||
|
||||
handler := icebergHandler.NewHandler(nil)
|
||||
|
||||
// Step 1: Expire snapshots
|
||||
expireConfig := icebergHandler.Config{
|
||||
SnapshotRetentionHours: 0, // instant expiry
|
||||
MaxSnapshotsToKeep: 1,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
result, _, err := handler.ExpireSnapshots(ctx, client, bucket, tablePath, expireConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "expired")
|
||||
t.Logf("Step 1 (expire): %s", result)
|
||||
|
||||
// Step 2: Remove orphans
|
||||
orphanConfig := icebergHandler.Config{
|
||||
OrphanOlderThanHours: 72,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
result, _, err = handler.RemoveOrphans(ctx, client, bucket, tablePath, orphanConfig)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Step 2 (orphans): %s", result)
|
||||
// The orphan and the unreferenced files from expired snapshots should be gone
|
||||
assert.Contains(t, result, "removed")
|
||||
|
||||
// Step 3: Verify metadata is consistent
|
||||
entry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
|
||||
require.NotNil(t, entry)
|
||||
xattr := entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
require.NotEmpty(t, xattr)
|
||||
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
|
||||
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should have advanced through the cycle")
|
||||
t.Logf("Final metadata version: %d", internalMeta.MetadataVersion)
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
# SeaweedFS Admin Component
|
||||
|
||||
A modern web-based administration interface for SeaweedFS clusters built with Go, Gin, Templ, and Bootstrap.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard**: Real-time cluster status and metrics
|
||||
- **Master Management**: Monitor master nodes and leadership status
|
||||
- **Volume Server Management**: View volume servers, capacity, and health
|
||||
- **Object Store Bucket Management**: Create, delete, and manage Object Store buckets with web interface
|
||||
- **S3 Tables Management**: Manage table buckets, namespaces, tables, tags, and policies via the admin UI
|
||||
- **System Health**: Overall cluster health monitoring
|
||||
- **Responsive Design**: Bootstrap-based UI that works on all devices
|
||||
- **Authentication**: Optional user authentication with sessions
|
||||
- **TLS Support**: HTTPS support for production deployments
|
||||
|
||||
## Building
|
||||
|
||||
### Using the Admin Makefile
|
||||
|
||||
The admin component has its own Makefile for development and building:
|
||||
|
||||
```bash
|
||||
# Navigate to admin directory
|
||||
cd weed/admin
|
||||
|
||||
# View all available targets
|
||||
make help
|
||||
|
||||
# Generate templates and build
|
||||
make build
|
||||
|
||||
# Development mode with template watching
|
||||
make dev
|
||||
|
||||
# Run the admin server
|
||||
make run
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Using the Root Makefile
|
||||
|
||||
The root SeaweedFS Makefile automatically integrates the admin component:
|
||||
|
||||
```bash
|
||||
# From the root directory
|
||||
make install # Builds weed with admin component
|
||||
make full_install # Full build with all tags
|
||||
make test # Runs tests including admin component
|
||||
|
||||
# Admin-specific targets from root
|
||||
make admin-generate # Generate admin templates
|
||||
make admin-build # Build admin component
|
||||
make admin-run # Run admin server
|
||||
make admin-dev # Development mode
|
||||
make admin-clean # Clean admin artifacts
|
||||
```
|
||||
|
||||
### Manual Building
|
||||
|
||||
If you prefer to build manually:
|
||||
|
||||
```bash
|
||||
# Install templ compiler
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
|
||||
# Generate templates
|
||||
templ generate
|
||||
|
||||
# Build the main weed binary
|
||||
cd ../../../
|
||||
go build -o weed ./weed
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Template Development
|
||||
|
||||
The admin interface uses [Templ](https://templ.guide/) for type-safe HTML templates:
|
||||
|
||||
```bash
|
||||
# Watch for template changes and auto-regenerate
|
||||
make watch
|
||||
|
||||
# Or manually generate templates
|
||||
make generate
|
||||
|
||||
# Format templates
|
||||
make fmt
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
weed/admin/
|
||||
├── Makefile # Admin-specific build tasks
|
||||
├── README.md # This file
|
||||
├── admin.go # Main application entry point
|
||||
├── dash/ # Server and handler logic
|
||||
│ ├── admin_server.go # HTTP server setup
|
||||
│ ├── handler_admin.go # Admin dashboard handlers
|
||||
│ ├── handler_auth.go # Authentication handlers
|
||||
│ └── middleware.go # HTTP middleware
|
||||
├── static/ # Static assets
|
||||
│ ├── css/admin.css # Admin-specific styles
|
||||
│ └── js/admin.js # Admin-specific JavaScript
|
||||
└── view/ # Templates
|
||||
├── app/ # Application templates
|
||||
│ ├── admin.templ # Main dashboard template
|
||||
│ ├── s3_buckets.templ # Object Store bucket management template
|
||||
│ ├── s3tables_*.templ # S3 Tables management templates
|
||||
│ └── *_templ.go # Generated Go code
|
||||
└── layout/ # Layout templates
|
||||
├── layout.templ # Base layout template
|
||||
└── layout_templ.go # Generated Go code
|
||||
```
|
||||
|
||||
### Object Store Management
|
||||
|
||||
The admin interface includes Object Store and S3 Tables management capabilities:
|
||||
|
||||
- Create/delete Object Store buckets and adjust quotas or ownership.
|
||||
- Manage S3 Tables buckets, namespaces, and tables.
|
||||
- Update S3 Tables policies and tags via the UI and API endpoints.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Start admin interface on default port (23646)
|
||||
weed admin
|
||||
|
||||
# Start with custom configuration
|
||||
weed admin -port=8080 -masters="master1:9333,master2:9333"
|
||||
|
||||
# Start with authentication
|
||||
weed admin -adminUser=admin -adminPassword=secret123
|
||||
|
||||
# Start with HTTPS
|
||||
weed admin -port=443 -tlsCert=/path/to/cert.pem -tlsKey=/path/to/key.pem
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-port` | 23646 | Admin server port |
|
||||
| `-masters` | localhost:9333 | Comma-separated master servers |
|
||||
| `-adminUser` | admin | Admin username (if auth enabled) |
|
||||
| `-adminPassword` | "" | Admin password (empty = no auth) |
|
||||
| `-tlsCert` | "" | Path to TLS certificate |
|
||||
| `-tlsKey` | "" | Path to TLS private key |
|
||||
|
||||
### Docker Usage
|
||||
|
||||
```bash
|
||||
# Build Docker image with admin component
|
||||
make docker-build
|
||||
|
||||
# Run with Docker
|
||||
docker run -p 23646:23646 seaweedfs/seaweedfs:latest admin -masters=host.docker.internal:9333
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <seaweedfs-repo>
|
||||
cd seaweedfs/weed/admin
|
||||
|
||||
# Install dependencies and build
|
||||
make install-deps
|
||||
make build
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
```
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. **Template Changes**: Edit `.templ` files in `view/`
|
||||
- Templates auto-regenerate in development mode
|
||||
- Use `make generate` to manually regenerate
|
||||
|
||||
2. **Go Code Changes**: Edit `.go` files
|
||||
- Restart the server to see changes
|
||||
- Use `make build` to rebuild
|
||||
|
||||
3. **Static Assets**: Edit files in `static/`
|
||||
- Changes are served immediately
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run admin component tests
|
||||
make test
|
||||
|
||||
# Run from root directory
|
||||
make admin-test
|
||||
|
||||
# Lint code
|
||||
make lint
|
||||
|
||||
# Format code
|
||||
make fmt
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Authentication**: Always set `adminPassword` in production
|
||||
2. **HTTPS**: Use TLS certificates for encrypted connections
|
||||
3. **Firewall**: Restrict admin interface access to authorized networks
|
||||
|
||||
### Example Production Setup
|
||||
|
||||
```bash
|
||||
# Production deployment with security
|
||||
weed admin \
|
||||
-port=443 \
|
||||
-masters="master1:9333,master2:9333,master3:9333" \
|
||||
-adminUser=admin \
|
||||
-adminPassword=your-secure-password \
|
||||
-tlsCert=/etc/ssl/certs/admin.crt \
|
||||
-tlsKey=/etc/ssl/private/admin.key
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
The admin interface provides endpoints for monitoring:
|
||||
|
||||
- `GET /health` - Health check endpoint
|
||||
- `GET /metrics` - Prometheus metrics (if enabled)
|
||||
- `GET /api/status` - JSON status information
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Templates not found**: Run `make generate` to create template files
|
||||
2. **Build errors**: Ensure `templ` is installed with `make install-templ`
|
||||
3. **Static files not loading**: Check that `static/` directory exists and has proper files
|
||||
4. **Connection errors**: Verify master and filer addresses are correct
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
weed -v=2 admin
|
||||
|
||||
# Check generated templates
|
||||
ls -la view/app/*_templ.go view/layout/*_templ.go
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests: `make test`
|
||||
5. Format code: `make fmt`
|
||||
6. Submit a pull request
|
||||
|
||||
## Architecture
|
||||
|
||||
The admin component follows a clean architecture:
|
||||
|
||||
- **Presentation Layer**: Templ templates + Bootstrap CSS
|
||||
- **HTTP Layer**: Gin router with middleware
|
||||
- **Business Logic**: Handler functions in `dash/` package
|
||||
- **Data Layer**: Communicates with SeaweedFS masters and filers
|
||||
|
||||
This separation makes the code maintainable and testable.
|
||||
@@ -111,6 +111,9 @@ type AdminServer struct {
|
||||
// Worker gRPC server
|
||||
workerGrpcServer *WorkerGrpcServer
|
||||
|
||||
// Background goroutine lifecycle
|
||||
bgCancel context.CancelFunc
|
||||
|
||||
// Collection statistics caching
|
||||
collectionStatsCache map[string]collectionStats
|
||||
lastCollectionStatsUpdate time.Time
|
||||
@@ -137,8 +140,8 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
|
||||
)
|
||||
|
||||
// Start master client connection process (like shell and filer do)
|
||||
ctx := context.Background()
|
||||
go masterClient.KeepConnectedToMaster(ctx)
|
||||
bgCtx, bgCancel := context.WithCancel(context.Background())
|
||||
go masterClient.KeepConnectedToMaster(bgCtx)
|
||||
|
||||
lockManager := NewAdminLockManager(masterClient, adminLockClientName)
|
||||
presenceLock := newAdminPresenceLock(masterClient)
|
||||
@@ -159,6 +162,7 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
|
||||
icebergPort: icebergPort,
|
||||
pluginLock: lockManager,
|
||||
adminPresenceLock: presenceLock,
|
||||
bgCancel: bgCancel,
|
||||
}
|
||||
|
||||
// Initialize topic retention purger
|
||||
@@ -256,11 +260,93 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
|
||||
} else {
|
||||
server.plugin = plugin
|
||||
glog.V(0).Infof("Plugin enabled")
|
||||
go server.monitorVacuumWorker(bgCtx)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// vacuumToggler abstracts the master's vacuum enable/disable for testing.
|
||||
type vacuumToggler interface {
|
||||
disableVacuum() error
|
||||
enableVacuum() error
|
||||
}
|
||||
|
||||
// masterVacuumToggler implements vacuumToggler via gRPC calls to the master.
|
||||
type masterVacuumToggler struct {
|
||||
server *AdminServer
|
||||
}
|
||||
|
||||
func (m *masterVacuumToggler) disableVacuum() error {
|
||||
return m.server.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := client.DisableVacuum(ctx, &master_pb.DisableVacuumRequest{ByPlugin: true})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (m *masterVacuumToggler) enableVacuum() error {
|
||||
return m.server.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := client.EnableVacuum(ctx, &master_pb.EnableVacuumRequest{ByPlugin: true})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// syncVacuumState performs a single sync step: checks if a vacuum-capable worker
|
||||
// is present and calls disable/enable accordingly. Returns the updated state
|
||||
// and whether the call failed (for log dedup on retries).
|
||||
func syncVacuumState(hasWorker bool, previouslyActive bool, toggler vacuumToggler, retrying bool) (active bool, failed bool) {
|
||||
if hasWorker == previouslyActive {
|
||||
return previouslyActive, false
|
||||
}
|
||||
if hasWorker {
|
||||
if !retrying {
|
||||
glog.V(0).Infof("Vacuum plugin worker connected, disabling master automatic vacuum")
|
||||
}
|
||||
if err := toggler.disableVacuum(); err != nil {
|
||||
glog.Warningf("Failed to disable vacuum on master: %v", err)
|
||||
return false, true // retry next tick
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
if !retrying {
|
||||
glog.V(0).Infof("Vacuum plugin worker disconnected, re-enabling master automatic vacuum")
|
||||
}
|
||||
if err := toggler.enableVacuum(); err != nil {
|
||||
glog.Warningf("Failed to enable vacuum on master: %v", err)
|
||||
return true, true // retry next tick
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// monitorVacuumWorker polls the plugin registry for vacuum-capable workers and
|
||||
// disables/enables the master's automatic scheduled vacuum accordingly.
|
||||
func (s *AdminServer) monitorVacuumWorker(ctx context.Context) {
|
||||
const pollInterval = 30 * time.Second
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
toggler := &masterVacuumToggler{server: s}
|
||||
vacuumWorkerActive := false
|
||||
retrying := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.plugin == nil {
|
||||
continue
|
||||
}
|
||||
hasWorker := s.plugin.HasCapableWorker("vacuum")
|
||||
vacuumWorkerActive, retrying = syncVacuumState(hasWorker, vacuumWorkerActive, toggler, retrying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadTaskConfigurationsFromPersistence loads saved task configurations from protobuf files
|
||||
func (s *AdminServer) loadTaskConfigurationsFromPersistence() {
|
||||
if s.configPersistence == nil || !s.configPersistence.IsConfigured() {
|
||||
@@ -1500,6 +1586,11 @@ func (s *AdminServer) UpdateTopicRetention(namespace, name string, enabled bool,
|
||||
func (s *AdminServer) Shutdown() {
|
||||
glog.V(1).Infof("Shutting down admin server...")
|
||||
|
||||
// Cancel background goroutines (vacuum monitor, etc.)
|
||||
if s.bgCancel != nil {
|
||||
s.bgCancel()
|
||||
}
|
||||
|
||||
// Stop maintenance manager
|
||||
s.StopMaintenanceManager()
|
||||
if s.adminPresenceLock != nil {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
|
||||
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
|
||||
)
|
||||
|
||||
// TestVacuumMonitorStateTransitions verifies the state transition logic that
|
||||
// drives the vacuum monitor goroutine: the plugin registry correctly reports
|
||||
// whether a vacuum-capable worker is present or absent.
|
||||
func TestVacuumMonitorStateTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := adminplugin.Options{
|
||||
DataDir: "",
|
||||
}
|
||||
p, err := adminplugin.New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create plugin: %v", err)
|
||||
}
|
||||
defer p.Shutdown()
|
||||
|
||||
// Initially no workers => no capable worker.
|
||||
if p.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected no capable worker initially")
|
||||
}
|
||||
|
||||
// Simulate state transition false -> true: worker connects.
|
||||
p.WorkerConnectForTest(&plugin_pb.WorkerHello{
|
||||
WorkerId: "vacuum-worker-1",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: true, CanExecute: true},
|
||||
},
|
||||
})
|
||||
|
||||
if !p.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected capable worker after connect")
|
||||
}
|
||||
|
||||
// Simulate state transition true -> false: worker disconnects.
|
||||
p.WorkerDisconnectForTest("vacuum-worker-1")
|
||||
|
||||
// Give registry removal a moment (it's synchronous, but be safe).
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if p.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected no capable worker after disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeToggler records disable/enable calls for testing.
|
||||
type fakeToggler struct {
|
||||
disableCalls int
|
||||
enableCalls int
|
||||
}
|
||||
|
||||
func (f *fakeToggler) disableVacuum() error {
|
||||
f.disableCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeToggler) enableVacuum() error {
|
||||
f.enableCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSyncVacuumState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("no change when state matches", func(t *testing.T) {
|
||||
tog := &fakeToggler{}
|
||||
result, failed := syncVacuumState(false, false, tog, false)
|
||||
if result != false || failed {
|
||||
t.Error("expected false, no failure")
|
||||
}
|
||||
result, failed = syncVacuumState(true, true, tog, false)
|
||||
if result != true || failed {
|
||||
t.Error("expected true, no failure")
|
||||
}
|
||||
if tog.disableCalls != 0 || tog.enableCalls != 0 {
|
||||
t.Errorf("expected no calls, got disable=%d enable=%d", tog.disableCalls, tog.enableCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("worker connects triggers disable", func(t *testing.T) {
|
||||
tog := &fakeToggler{}
|
||||
result, failed := syncVacuumState(true, false, tog, false)
|
||||
if result != true || failed {
|
||||
t.Error("expected true after disable, no failure")
|
||||
}
|
||||
if tog.disableCalls != 1 {
|
||||
t.Errorf("expected 1 disable call, got %d", tog.disableCalls)
|
||||
}
|
||||
if tog.enableCalls != 0 {
|
||||
t.Errorf("expected 0 enable calls, got %d", tog.enableCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("worker disconnects triggers enable", func(t *testing.T) {
|
||||
tog := &fakeToggler{}
|
||||
result, failed := syncVacuumState(false, true, tog, false)
|
||||
if result != false || failed {
|
||||
t.Error("expected false after enable, no failure")
|
||||
}
|
||||
if tog.enableCalls != 1 {
|
||||
t.Errorf("expected 1 enable call, got %d", tog.enableCalls)
|
||||
}
|
||||
if tog.disableCalls != 0 {
|
||||
t.Errorf("expected 0 disable calls, got %d", tog.disableCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,8 @@ func (h *PluginHandlers) ShowPluginMonitoring(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (h *PluginHandlers) renderPluginPage(w http.ResponseWriter, r *http.Request, page string) {
|
||||
component := app.Plugin(page)
|
||||
initialJob := r.URL.Query().Get("job")
|
||||
component := app.Plugin(page, initialJob)
|
||||
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
|
||||
layoutComponent := layout.Layout(viewCtx, component)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ type Plugin struct {
|
||||
schedulerRun map[string]*schedulerRunInfo
|
||||
schedulerLoopMu sync.Mutex
|
||||
schedulerLoopState schedulerLoopState
|
||||
schedulerWakeCh chan struct{}
|
||||
schedulerWakeCh chan struct{}
|
||||
|
||||
dedupeMu sync.Mutex
|
||||
recentDedupeByType map[string]map[string]time.Time
|
||||
@@ -392,7 +392,6 @@ func (r *Plugin) SaveJobTypeConfig(config *plugin_pb.PersistedJobTypeConfig) err
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Plugin) LoadDescriptor(jobType string) (*plugin_pb.JobTypeDescriptor, error) {
|
||||
return r.store.LoadDescriptor(jobType)
|
||||
}
|
||||
@@ -688,6 +687,11 @@ func (r *Plugin) executeJobWithExecutor(
|
||||
}
|
||||
}
|
||||
|
||||
// HasCapableWorker checks if any non-stale worker has a capability for the given job type.
|
||||
func (r *Plugin) HasCapableWorker(jobType string) bool {
|
||||
return r.registry.HasCapableWorker(jobType)
|
||||
}
|
||||
|
||||
func (r *Plugin) ListWorkers() []*WorkerSession {
|
||||
return r.registry.List()
|
||||
}
|
||||
@@ -1414,3 +1418,13 @@ func (s *streamSession) close() {
|
||||
close(s.outgoing)
|
||||
})
|
||||
}
|
||||
|
||||
// WorkerConnectForTest simulates a worker connecting (test helper).
|
||||
func (r *Plugin) WorkerConnectForTest(hello *plugin_pb.WorkerHello) {
|
||||
r.registry.UpsertFromHello(hello)
|
||||
}
|
||||
|
||||
// WorkerDisconnectForTest simulates a worker disconnecting (test helper).
|
||||
func (r *Plugin) WorkerDisconnectForTest(workerID string) {
|
||||
r.registry.Remove(workerID)
|
||||
}
|
||||
|
||||
@@ -120,6 +120,28 @@ func (r *Registry) List() []*WorkerSession {
|
||||
return out
|
||||
}
|
||||
|
||||
// HasCapableWorker checks if any non-stale worker session has a capability for the given job type.
|
||||
// A worker is capable if its capabilities include the job type with CanDetect or CanExecute true.
|
||||
func (r *Registry) HasCapableWorker(jobType string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
for _, session := range r.sessions {
|
||||
if r.isSessionStaleLocked(session, now) {
|
||||
continue
|
||||
}
|
||||
capability := session.Capabilities[jobType]
|
||||
if capability == nil {
|
||||
continue
|
||||
}
|
||||
if capability.CanDetect || capability.CanExecute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DetectableJobTypes returns sorted job types that currently have at least one detect-capable worker.
|
||||
func (r *Registry) DetectableJobTypes() []string {
|
||||
r.mu.RLock()
|
||||
|
||||
@@ -319,3 +319,85 @@ func TestRegistryReturnsNoDetectorWhenAllWorkersStale(t *testing.T) {
|
||||
t.Fatalf("expected no detector when all workers are stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerWithDetectCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-a",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: true, CanExecute: false},
|
||||
},
|
||||
})
|
||||
|
||||
if !r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return true for detect-capable worker")
|
||||
}
|
||||
if r.HasCapableWorker("balance") {
|
||||
t.Fatalf("expected HasCapableWorker to return false for unknown job type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerWithExecuteCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-a",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: false, CanExecute: true},
|
||||
},
|
||||
})
|
||||
|
||||
if !r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return true for execute-capable worker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerReturnsFalseWhenNoWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
if r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return false with no workers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerSkipsStaleWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.staleAfter = 2 * time.Second
|
||||
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-stale",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: true, CanExecute: true},
|
||||
},
|
||||
})
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions["worker-stale"].LastSeenAt = time.Now().Add(-10 * time.Second)
|
||||
r.mu.Unlock()
|
||||
|
||||
if r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return false when all workers are stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHasCapableWorkerIgnoresNilCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRegistry()
|
||||
r.UpsertFromHello(&plugin_pb.WorkerHello{
|
||||
WorkerId: "worker-a",
|
||||
Capabilities: []*plugin_pb.JobTypeCapability{
|
||||
{JobType: "vacuum", CanDetect: false, CanExecute: false},
|
||||
},
|
||||
})
|
||||
|
||||
if r.HasCapableWorker("vacuum") {
|
||||
t.Fatalf("expected HasCapableWorker to return false when capability has no detect or execute")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ func CalculateTaskStorageImpact(taskType TaskType, volumeSize int64) (sourceChan
|
||||
// Replication task: creates new replica on target
|
||||
return StorageSlotChange{VolumeSlots: 0, ShardSlots: 0}, StorageSlotChange{VolumeSlots: 1, ShardSlots: 0}
|
||||
|
||||
case TaskTypeECBalance:
|
||||
// EC balance task: moves individual shard from source to target
|
||||
// Source frees 1 shard slot, target gains 1 shard slot
|
||||
return StorageSlotChange{VolumeSlots: 0, ShardSlots: -1}, StorageSlotChange{VolumeSlots: 0, ShardSlots: 1}
|
||||
|
||||
default:
|
||||
// Unknown task type, assume minimal impact
|
||||
glog.Warningf("unhandled task type %s in CalculateTaskStorageImpact, assuming default impact", taskType)
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
TaskTypeBalance TaskType = "balance"
|
||||
TaskTypeErasureCoding TaskType = "erasure_coding"
|
||||
TaskTypeReplication TaskType = "replication"
|
||||
TaskTypeECBalance TaskType = "ec_balance"
|
||||
)
|
||||
|
||||
// Common task status constants
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package app
|
||||
|
||||
templ Plugin(page string) {
|
||||
templ Plugin(page string, initialJob string) {
|
||||
{{
|
||||
currentPage := page
|
||||
if currentPage == "" {
|
||||
currentPage = "overview"
|
||||
}
|
||||
}}
|
||||
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage }>
|
||||
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage } data-plugin-job={ initialJob }>
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
@@ -589,6 +589,7 @@ templ Plugin(page string) {
|
||||
jobTypes: [],
|
||||
lastDetectionByJobType: {},
|
||||
initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),
|
||||
initialJob: String(page.getAttribute('data-plugin-job') || '').trim(),
|
||||
activeTopTab: 'overview',
|
||||
activeSubTab: 'configuration',
|
||||
initialNavigationApplied: false,
|
||||
@@ -717,6 +718,28 @@ templ Plugin(page string) {
|
||||
return 'configuration';
|
||||
}
|
||||
|
||||
function buildPluginURL(topTab, subTab, jobType) {
|
||||
if (topTab === 'overview') {
|
||||
return '/plugin';
|
||||
}
|
||||
var effectiveSubTab = normalizeSubTab(subTab);
|
||||
var path = '/plugin/' + encodeURIComponent(effectiveSubTab);
|
||||
var jt = String(jobType || '').trim();
|
||||
if (jt) {
|
||||
path += '?job=' + encodeURIComponent(jt);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function updateURL(replace) {
|
||||
var url = buildPluginURL(state.activeTopTab, state.activeSubTab, state.selectedJobType);
|
||||
if (replace) {
|
||||
history.replaceState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
|
||||
} else {
|
||||
history.pushState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a job type item (string or object) into a safe object shape
|
||||
function parseJobTypeItem(item) {
|
||||
var jobType = '';
|
||||
@@ -782,8 +805,13 @@ templ Plugin(page string) {
|
||||
state.activeTopTab = 'overview';
|
||||
return;
|
||||
}
|
||||
var firstItem = parseJobTypeItem(state.jobTypes[0]);
|
||||
state.selectedJobType = firstItem.jobType;
|
||||
// If a specific job type was provided in the URL, use it
|
||||
if (state.initialJob && hasJobType(state.initialJob)) {
|
||||
state.selectedJobType = state.initialJob;
|
||||
} else {
|
||||
var firstItem = parseJobTypeItem(state.jobTypes[0]);
|
||||
state.selectedJobType = firstItem.jobType;
|
||||
}
|
||||
state.activeTopTab = topTabKeyForJobType(state.selectedJobType);
|
||||
}
|
||||
|
||||
@@ -3015,6 +3043,7 @@ templ Plugin(page string) {
|
||||
if (topTabKey === 'overview') {
|
||||
state.activeTopTab = 'overview';
|
||||
renderNavigationState();
|
||||
updateURL(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3026,6 +3055,7 @@ templ Plugin(page string) {
|
||||
state.selectedJobType = jobType;
|
||||
ensureActiveNavigation();
|
||||
renderNavigationState();
|
||||
updateURL(false);
|
||||
if (state.loadedJobType !== jobType) {
|
||||
try {
|
||||
await loadDescriptorAndConfig(jobType, false);
|
||||
@@ -3053,6 +3083,7 @@ templ Plugin(page string) {
|
||||
var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));
|
||||
state.activeSubTab = subTabKey;
|
||||
renderNavigationState();
|
||||
updateURL(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3076,11 +3107,44 @@ templ Plugin(page string) {
|
||||
renderNavigationState();
|
||||
await refreshAll();
|
||||
|
||||
// Set the initial browser history state to match the current view
|
||||
updateURL(true);
|
||||
|
||||
state.refreshTimer = setInterval(function() {
|
||||
refreshAll();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', async function(event) {
|
||||
var s = event.state;
|
||||
if (!s || !s.pluginTopTab) {
|
||||
// No plugin state — treat as overview
|
||||
state.activeTopTab = 'overview';
|
||||
renderNavigationState();
|
||||
return;
|
||||
}
|
||||
state.activeTopTab = String(s.pluginTopTab || 'overview');
|
||||
state.activeSubTab = normalizeSubTab(s.pluginSubTab);
|
||||
var jobType = String(s.pluginJob || '').trim();
|
||||
if (jobType) {
|
||||
state.selectedJobType = jobType;
|
||||
}
|
||||
ensureActiveNavigation();
|
||||
renderNavigationState();
|
||||
// If a job type is selected and not loaded yet, load it
|
||||
if (state.selectedJobType && state.loadedJobType !== state.selectedJobType) {
|
||||
try {
|
||||
await loadDescriptorAndConfig(state.selectedJobType, false);
|
||||
} catch (e) {
|
||||
console.error('Failed to load data on popstate navigation:', e);
|
||||
}
|
||||
}
|
||||
renderQueueJobs();
|
||||
renderDetectionJobs();
|
||||
renderExecutionJobs();
|
||||
renderExecutionActivities();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (state.refreshTimer) {
|
||||
clearInterval(state.refreshTimer);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -290,6 +290,9 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
<div class="container-fluid text-center">
|
||||
<small class="text-muted">
|
||||
© {fmt.Sprintf("%d", time.Now().Year())} SeaweedFS Admin v{version.VERSION_NUMBER}
|
||||
if version.COMMIT != "" {
|
||||
<span class="mx-1">({version.COMMIT})</span>
|
||||
}
|
||||
if !strings.Contains(version.VERSION, "enterprise") {
|
||||
<span class="mx-2">•</span>
|
||||
<a href="https://seaweedfs.com" target="_blank" class="text-decoration-none">
|
||||
|
||||
@@ -307,13 +307,32 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !strings.Contains(version.VERSION, "enterprise") {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
|
||||
if version.COMMIT != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"mx-1\">(")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.COMMIT)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 294, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</small></div></footer><!-- Bootstrap JS --><script src=\"/static/js/bootstrap.bundle.min.js\"></script><!-- Modal Alerts JS (replaces native alert/confirm) --><script src=\"/static/js/modal-alerts.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script><script src=\"/static/js/iam-utils.js\"></script><script src=\"/static/js/s3tables.js\"></script></body></html>")
|
||||
if !strings.Contains(version.VERSION, "enterprise") {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</small></div></footer><!-- Bootstrap JS --><script src=\"/static/js/bootstrap.bundle.min.js\"></script><!-- Modal Alerts JS (replaces native alert/confirm) --><script src=\"/static/js/modal-alerts.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script><script src=\"/static/js/iam-utils.js\"></script><script src=\"/static/js/s3tables.js\"></script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -337,74 +356,74 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var16 == nil {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
templ_7745c5c3_Var17 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var17 == nil {
|
||||
templ_7745c5c3_Var17 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 320, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 334, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 323, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 341, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 344, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<form method=\"POST\" action=\"/login\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<form method=\"POST\" action=\"/login\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 346, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 349, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"/static/js/bootstrap.bundle.min.js\"></script></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"/static/js/bootstrap.bundle.min.js\"></script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/spf13/viper"
|
||||
@@ -98,6 +100,9 @@ var cmdAdmin = &Command{
|
||||
- IMPORTANT: When read-only credentials are configured, adminPassword MUST also be set
|
||||
- This ensures an admin account exists to manage and authorize read-only access
|
||||
- Sessions are secured with auto-generated session keys
|
||||
- Credentials can also be set via security.toml [admin] section or environment variables:
|
||||
WEED_ADMIN_USER, WEED_ADMIN_PASSWORD, WEED_ADMIN_READONLY_USER, WEED_ADMIN_READONLY_PASSWORD
|
||||
- Precedence: CLI flag > env var / security.toml > default value
|
||||
|
||||
Security Configuration:
|
||||
- The admin server reads TLS configuration from security.toml
|
||||
@@ -137,6 +142,13 @@ func runAdmin(cmd *Command, args []string) bool {
|
||||
// Load security configuration
|
||||
util.LoadSecurityConfiguration()
|
||||
|
||||
// Apply security.toml / env var fallbacks for credential flags.
|
||||
// CLI flags take precedence over security.toml / WEED_* env vars.
|
||||
applyViperFallback(cmd, a.adminUser, "adminUser", "admin.user")
|
||||
applyViperFallback(cmd, a.adminPassword, "adminPassword", "admin.password")
|
||||
applyViperFallback(cmd, a.readOnlyUser, "readOnlyUser", "admin.readonly.user")
|
||||
applyViperFallback(cmd, a.readOnlyPassword, "readOnlyPassword", "admin.readonly.password")
|
||||
|
||||
// Backward compatibility: if -masters is provided, use it
|
||||
if *a.masters != "" {
|
||||
*a.master = *a.masters
|
||||
@@ -534,6 +546,22 @@ func loadOrGenerateSessionKeys(dataDir string) ([]byte, []byte, error) {
|
||||
return key[:keyLen], key[keyLen:], nil
|
||||
}
|
||||
|
||||
// applyViperFallback sets a flag's value from viper (security.toml / env var)
|
||||
// when the flag was not explicitly set on the command line.
|
||||
func applyViperFallback(cmd *Command, flagPtr *string, flagName, viperKey string) {
|
||||
explicitlySet := false
|
||||
cmd.Flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == flagName {
|
||||
explicitlySet = true
|
||||
}
|
||||
})
|
||||
if !explicitlySet {
|
||||
if v := util.GetViper().GetString(viperKey); v != "" {
|
||||
*flagPtr = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandHomeDir expands the tilde (~) in a path to the user's home directory
|
||||
func expandHomeDir(path string) (string, error) {
|
||||
if path == "" {
|
||||
|
||||
@@ -45,7 +45,7 @@ const (
|
||||
defaultMiniVolumeSizeMB = 128 // Default volume size for mini mode
|
||||
maxVolumeSizeMB = 1024 // Maximum volume size in MB (1GB)
|
||||
GrpcPortOffset = 10000 // Offset used to calculate gRPC port from HTTP port
|
||||
defaultMiniPluginJobTypes = "vacuum,volume_balance,erasure_coding,admin_script"
|
||||
defaultMiniPluginJobTypes = "all"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
|
||||
func TestMiniDefaultPluginJobTypes(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
// defaultMiniPluginJobTypes is an explicit list: "vacuum,volume_balance,erasure_coding,admin_script"
|
||||
// defaultMiniPluginJobTypes is "all", which includes every registered handler
|
||||
handlers, err := buildPluginWorkerHandlers(defaultMiniPluginJobTypes, dialOption, int(pluginworker.DefaultMaxExecutionConcurrency), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(mini default) err = %v", err)
|
||||
}
|
||||
if len(handlers) != 4 {
|
||||
t.Fatalf("expected mini default job types to include 4 handlers, got %d", len(handlers))
|
||||
if len(handlers) != 6 {
|
||||
t.Fatalf("expected mini default job types to include 6 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,19 @@ cert = ""
|
||||
key = ""
|
||||
ca = ""
|
||||
|
||||
# Admin server authentication
|
||||
# If password is set, users must login to access the admin interface
|
||||
# These can be overridden by environment variables with WEED_ prefix:
|
||||
# WEED_ADMIN_USER, WEED_ADMIN_PASSWORD
|
||||
# WEED_ADMIN_READONLY_USER, WEED_ADMIN_READONLY_PASSWORD
|
||||
[admin]
|
||||
user = ""
|
||||
password = ""
|
||||
|
||||
[admin.readonly]
|
||||
user = ""
|
||||
password = ""
|
||||
|
||||
# white list. It's checking request ip address.
|
||||
[guard]
|
||||
white_list = ""
|
||||
|
||||
@@ -351,7 +351,7 @@ func parseVolumeTags(tagsArg string, folderCount int) [][]string {
|
||||
tagEntries = strings.Split(tagsArg, ",")
|
||||
}
|
||||
folderTags := make([][]string, folderCount)
|
||||
|
||||
|
||||
// If exactly one tag entry provided, replicate it to all folders
|
||||
if len(tagEntries) == 1 {
|
||||
normalized := util.NormalizeTagList(strings.Split(tagEntries[0], ":"))
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestWorkerDefaultJobTypes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(default worker flag) err = %v", err)
|
||||
}
|
||||
// Expected: vacuum, volume_balance, admin_script, erasure_coding, iceberg_maintenance
|
||||
if len(handlers) != 5 {
|
||||
t.Fatalf("expected default worker job types to include 5 handlers, got %d", len(handlers))
|
||||
// Expected: vacuum, volume_balance, admin_script, erasure_coding, iceberg_maintenance, ec_balance
|
||||
if len(handlers) != 6 {
|
||||
t.Fatalf("expected default worker job types to include 6 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type Filer struct {
|
||||
FilerConf *FilerConf
|
||||
RemoteStorage *FilerRemoteStorage
|
||||
lazyFetchGroup singleflight.Group
|
||||
lazyListGroup singleflight.Group
|
||||
Dlm *lock_manager.DistributedLockManager
|
||||
MaxFilenameLength uint32
|
||||
deletionQuit chan struct{}
|
||||
@@ -389,6 +390,8 @@ func (f *Filer) FindEntry(ctx context.Context, p util.FullPath) (entry *Entry, e
|
||||
}
|
||||
|
||||
func (f *Filer) doListDirectoryEntries(ctx context.Context, p util.FullPath, startFileName string, inclusive bool, limit int64, prefix string, eachEntryFunc ListEachEntryFunc) (expiredCount int64, lastFileName string, err error) {
|
||||
f.maybeLazyListFromRemote(ctx, p)
|
||||
|
||||
// Collect expired entries during iteration to avoid deadlock with DB connection pool
|
||||
var expiredEntries []*Entry
|
||||
var s3ExpiredEntries []*Entry
|
||||
|
||||
@@ -91,8 +91,19 @@ func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry
|
||||
lastFileName = sub.Name()
|
||||
if sub.IsDirectory() {
|
||||
subIsDeletingBucket := f.IsBucket(sub)
|
||||
err = f.doBatchDeleteFolderMetaAndData(ctx, sub, isRecursive, ignoreRecursiveError, shouldDeleteChunks, subIsDeletingBucket, false, nil, onHardLinkIdsFn)
|
||||
err = f.doBatchDeleteFolderMetaAndData(ctx, sub, isRecursive, ignoreRecursiveError, shouldDeleteChunks, subIsDeletingBucket, isFromOtherCluster, nil, onHardLinkIdsFn)
|
||||
} else {
|
||||
if !isFromOtherCluster {
|
||||
if _, remoteErr := f.maybeDeleteFromRemote(ctx, sub); remoteErr != nil {
|
||||
glog.Warningf("remote delete child %s: %v", sub.FullPath, remoteErr)
|
||||
if !ignoreRecursiveError {
|
||||
err = remoteErr
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil && !ignoreRecursiveError {
|
||||
break
|
||||
}
|
||||
f.NotifyUpdateEvent(ctx, sub, nil, shouldDeleteChunks, isFromOtherCluster, nil)
|
||||
if len(sub.HardLinkId) != 0 {
|
||||
// hard link chunk data are deleted separately
|
||||
@@ -130,9 +141,16 @@ func (f *Filer) doDeleteEntryMetaAndData(ctx context.Context, entry *Entry, shou
|
||||
|
||||
glog.V(3).InfofCtx(ctx, "deleting entry %v, delete chunks: %v", entry.FullPath, shouldDeleteChunks)
|
||||
|
||||
if !isFromOtherCluster {
|
||||
if _, remoteDeletionErr := f.maybeDeleteFromRemote(ctx, entry); remoteDeletionErr != nil {
|
||||
return remoteDeletionErr
|
||||
}
|
||||
}
|
||||
|
||||
if storeDeletionErr := f.Store.DeleteOneEntry(ctx, entry); storeDeletionErr != nil {
|
||||
return fmt.Errorf("filer store delete: %w", storeDeletionErr)
|
||||
}
|
||||
|
||||
if !entry.IsDirectory() {
|
||||
f.NotifyUpdateEvent(ctx, entry, nil, shouldDeleteChunks, isFromOtherCluster, signatures)
|
||||
}
|
||||
|
||||
@@ -114,3 +114,49 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
|
||||
}
|
||||
return result.entry, nil
|
||||
}
|
||||
|
||||
func (f *Filer) maybeDeleteFromRemote(ctx context.Context, entry *Entry) (bool, error) {
|
||||
if entry == nil || f.RemoteStorage == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(entry.FullPath)
|
||||
if remoteLoc == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
client, _, found := f.RemoteStorage.GetRemoteStorageClient(remoteLoc.Name)
|
||||
if !found {
|
||||
return false, fmt.Errorf("resolve remote storage client for %s: not found", entry.FullPath)
|
||||
}
|
||||
if client == nil {
|
||||
return false, fmt.Errorf("resolve remote storage client for %s: initialization failed", entry.FullPath)
|
||||
}
|
||||
|
||||
objectLoc := MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, entry.FullPath)
|
||||
|
||||
if entry.IsDirectory() {
|
||||
if err := client.RemoveDirectory(objectLoc); err != nil {
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("remove remote directory %s: %w", entry.FullPath, err)
|
||||
}
|
||||
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted directory %s from remote", entry.FullPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if entry.Remote == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := client.DeleteFile(objectLoc); err != nil {
|
||||
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("delete remote file %s: %w", entry.FullPath, err)
|
||||
}
|
||||
|
||||
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted %s from remote", entry.FullPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const xattrRemoteListingSyncedAt = "remote.listing.synced_at"
|
||||
|
||||
type lazyListContextKey struct{}
|
||||
|
||||
// maybeLazyListFromRemote populates the local filer store with entries from the
|
||||
// remote storage backend for directory p if the following conditions hold:
|
||||
// - p is under a remote mount with listing_cache_ttl_seconds > 0
|
||||
// - the cached listing has expired (based on the per-mount TTL)
|
||||
//
|
||||
// When listing_cache_ttl_seconds is 0 (the default), lazy listing is disabled
|
||||
// for that mount.
|
||||
//
|
||||
// On success it updates the directory's xattrRemoteListingSyncedAt extended
|
||||
// attribute so subsequent calls within the TTL window are no-ops.
|
||||
//
|
||||
// Errors are logged and swallowed (availability over consistency).
|
||||
func (f *Filer) maybeLazyListFromRemote(ctx context.Context, p util.FullPath) {
|
||||
// Prevent recursion: CreateEntry → FindEntry → doListDirectoryEntries → here
|
||||
if ctx.Value(lazyListContextKey{}) != nil {
|
||||
return
|
||||
}
|
||||
// Also respect the lazy-fetch guard to prevent mutual recursion
|
||||
if ctx.Value(lazyFetchContextKey{}) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if f.RemoteStorage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// The ptrie stores mount rules with trailing "/". When p is exactly the
|
||||
// mount directory (e.g. "/buckets/mybucket"), we must also try matching
|
||||
// with a trailing "/" so the trie recognizes the mount root.
|
||||
lookupPath := p
|
||||
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(lookupPath)
|
||||
if remoteLoc == nil {
|
||||
lookupPath = util.FullPath(string(p) + "/")
|
||||
mountDir, remoteLoc = f.RemoteStorage.FindMountDirectory(lookupPath)
|
||||
if remoteLoc == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy listing is opt-in: disabled when TTL is 0
|
||||
if remoteLoc.ListingCacheTtlSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
cacheTTL := time.Duration(remoteLoc.ListingCacheTtlSeconds) * time.Second
|
||||
|
||||
// Check staleness: read the directory entry's extended attributes.
|
||||
// Use Store.FindEntry directly — we only need the local xattr, not lazy-fetch.
|
||||
dirEntry, _ := f.Store.FindEntry(ctx, p)
|
||||
if dirEntry != nil {
|
||||
if syncedAtStr, ok := dirEntry.Extended[xattrRemoteListingSyncedAt]; ok {
|
||||
if syncedAt, err := strconv.ParseInt(string(syncedAtStr), 10, 64); err == nil {
|
||||
if time.Since(time.Unix(syncedAt, 0)) < cacheTTL {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client, _, found := f.RemoteStorage.FindRemoteStorageClient(lookupPath)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
key := "list:" + string(p)
|
||||
f.lazyListGroup.Do(key, func() (interface{}, error) {
|
||||
startTime := time.Now()
|
||||
objectLoc := MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, p)
|
||||
|
||||
// Decouple from the caller's cancellation/deadline while preserving
|
||||
// trace/span values for distributed tracing.
|
||||
persistCtx := context.WithValue(context.WithoutCancel(ctx), lazyListContextKey{}, true)
|
||||
persistCtx = context.WithValue(persistCtx, lazyFetchContextKey{}, true)
|
||||
|
||||
listErr := client.ListDirectory(persistCtx, objectLoc, func(dir string, name string, isDirectory bool, remoteEntry *filer_pb.RemoteEntry) error {
|
||||
childPath := p.Child(name)
|
||||
|
||||
existingEntry, findErr := f.Store.FindEntry(persistCtx, childPath)
|
||||
if findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
glog.Warningf("maybeLazyListFromRemote: find %s: %v", childPath, findErr)
|
||||
return nil // skip this entry on transient store error
|
||||
}
|
||||
|
||||
// Skip entries that exist locally without a RemoteEntry (local-only uploads)
|
||||
if existingEntry != nil && existingEntry.Remote == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if existingEntry != nil {
|
||||
// Merge: update remote metadata while preserving local state
|
||||
// (Chunks, Extended, Uid/Gid/Mode, etc.)
|
||||
existingEntry.Remote = remoteEntry
|
||||
if !isDirectory && remoteEntry != nil {
|
||||
if remoteEntry.RemoteMtime > 0 {
|
||||
existingEntry.Attr.Mtime = time.Unix(remoteEntry.RemoteMtime, 0)
|
||||
}
|
||||
existingEntry.Attr.FileSize = uint64(remoteEntry.RemoteSize)
|
||||
}
|
||||
if saveErr := f.Store.UpdateEntry(persistCtx, existingEntry); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: update %s: %v", childPath, saveErr)
|
||||
}
|
||||
} else {
|
||||
// New entry not yet in local store
|
||||
var entry *Entry
|
||||
if isDirectory {
|
||||
now := time.Now()
|
||||
entry = &Entry{
|
||||
FullPath: childPath,
|
||||
Attr: Attr{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
Mode: os.ModeDir | 0755,
|
||||
Uid: OS_UID,
|
||||
Gid: OS_GID,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mtime := time.Now()
|
||||
if remoteEntry != nil && remoteEntry.RemoteMtime > 0 {
|
||||
mtime = time.Unix(remoteEntry.RemoteMtime, 0)
|
||||
}
|
||||
entry = &Entry{
|
||||
FullPath: childPath,
|
||||
Attr: Attr{
|
||||
Mtime: mtime,
|
||||
Crtime: mtime,
|
||||
Mode: 0644,
|
||||
Uid: OS_UID,
|
||||
Gid: OS_GID,
|
||||
},
|
||||
Remote: remoteEntry,
|
||||
}
|
||||
if remoteEntry != nil {
|
||||
entry.Attr.FileSize = uint64(remoteEntry.RemoteSize)
|
||||
}
|
||||
}
|
||||
if saveErr := f.CreateEntry(persistCtx, entry, false, false, nil, true, f.MaxFilenameLength); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: persist %s: %v", childPath, saveErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if listErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: list %s: %v", p, listErr)
|
||||
return nil, nil // swallow error
|
||||
}
|
||||
|
||||
// Update the synced_at timestamp on the directory entry
|
||||
f.updateDirectoryListingSyncedAt(persistCtx, p, startTime)
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Filer) updateDirectoryListingSyncedAt(ctx context.Context, p util.FullPath, syncTime time.Time) {
|
||||
dirEntry, findErr := f.Store.FindEntry(ctx, p)
|
||||
if findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
glog.Warningf("maybeLazyListFromRemote: find dir %s: %v", p, findErr)
|
||||
return
|
||||
}
|
||||
if errors.Is(findErr, filer_pb.ErrNotFound) {
|
||||
// Directory doesn't exist yet, create it
|
||||
now := time.Now()
|
||||
dirEntry = &Entry{
|
||||
FullPath: p,
|
||||
Attr: Attr{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
Mode: os.ModeDir | 0755,
|
||||
Uid: OS_UID,
|
||||
Gid: OS_GID,
|
||||
},
|
||||
}
|
||||
if dirEntry.Extended == nil {
|
||||
dirEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
dirEntry.Extended[xattrRemoteListingSyncedAt] = []byte(fmt.Sprintf("%d", syncTime.Unix()))
|
||||
if saveErr := f.CreateEntry(ctx, dirEntry, false, false, nil, true, f.MaxFilenameLength); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: create dir synced_at for %s: %v", p, saveErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if dirEntry.Extended == nil {
|
||||
dirEntry.Extended = make(map[string][]byte)
|
||||
}
|
||||
dirEntry.Extended[xattrRemoteListingSyncedAt] = []byte(fmt.Sprintf("%d", syncTime.Unix()))
|
||||
if saveErr := f.Store.UpdateEntry(ctx, dirEntry); saveErr != nil {
|
||||
glog.Warningf("maybeLazyListFromRemote: update synced_at for %s: %v", p, saveErr)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -26,34 +29,128 @@ import (
|
||||
// --- minimal FilerStore stub ---
|
||||
|
||||
type stubFilerStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*Entry
|
||||
insertErr error
|
||||
mu sync.Mutex
|
||||
entries map[string]*Entry
|
||||
kv map[string][]byte
|
||||
insertErr error
|
||||
deleteErrByPath map[string]error
|
||||
}
|
||||
|
||||
func newStubFilerStore() *stubFilerStore {
|
||||
return &stubFilerStore{entries: make(map[string]*Entry)}
|
||||
return &stubFilerStore{
|
||||
entries: make(map[string]*Entry),
|
||||
kv: make(map[string][]byte),
|
||||
deleteErrByPath: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) GetName() string { return "stub" }
|
||||
func (s *stubFilerStore) GetName() string { return "stub" }
|
||||
func (s *stubFilerStore) Initialize(util.Configuration, string) error { return nil }
|
||||
func (s *stubFilerStore) Shutdown() {}
|
||||
func (s *stubFilerStore) Shutdown() {}
|
||||
func (s *stubFilerStore) BeginTransaction(ctx context.Context) (context.Context, error) {
|
||||
return ctx, nil
|
||||
}
|
||||
func (s *stubFilerStore) CommitTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) RollbackTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) KvPut(context.Context, []byte, []byte) error { return nil }
|
||||
func (s *stubFilerStore) KvGet(context.Context, []byte) ([]byte, error) {
|
||||
return nil, ErrKvNotFound
|
||||
func (s *stubFilerStore) CommitTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) RollbackTransaction(context.Context) error { return nil }
|
||||
func (s *stubFilerStore) KvPut(_ context.Context, key []byte, value []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.kv[string(key)] = append([]byte(nil), value...)
|
||||
return nil
|
||||
}
|
||||
func (s *stubFilerStore) KvDelete(context.Context, []byte) error { return nil }
|
||||
func (s *stubFilerStore) DeleteFolderChildren(context.Context, util.FullPath) error { return nil }
|
||||
func (s *stubFilerStore) ListDirectoryEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
return "", nil
|
||||
func (s *stubFilerStore) KvGet(_ context.Context, key []byte) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
value, found := s.kv[string(key)]
|
||||
if !found {
|
||||
return nil, ErrKvNotFound
|
||||
}
|
||||
return append([]byte(nil), value...), nil
|
||||
}
|
||||
func (s *stubFilerStore) ListDirectoryPrefixedEntries(ctx context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
return "", nil
|
||||
func (s *stubFilerStore) KvDelete(_ context.Context, key []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.kv, string(key))
|
||||
return nil
|
||||
}
|
||||
func (s *stubFilerStore) DeleteFolderChildren(_ context.Context, dirPath util.FullPath) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prefix := string(dirPath) + "/"
|
||||
for k := range s.entries {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(s.entries, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *stubFilerStore) listDirectoryChildNames(dirPath util.FullPath, startFileName string, includeStartFile bool, namePrefix string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dirPrefix := string(dirPath) + "/"
|
||||
var names []string
|
||||
for k := range s.entries {
|
||||
if !strings.HasPrefix(k, dirPrefix) {
|
||||
continue
|
||||
}
|
||||
rest := k[len(dirPrefix):]
|
||||
if strings.Contains(rest, "/") {
|
||||
continue
|
||||
}
|
||||
if namePrefix != "" && !strings.HasPrefix(rest, namePrefix) {
|
||||
continue
|
||||
}
|
||||
if rest > startFileName || (includeStartFile && rest == startFileName) {
|
||||
names = append(names, rest)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
func (s *stubFilerStore) getEntry(path string) *Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.entries[path]
|
||||
}
|
||||
func (s *stubFilerStore) ListDirectoryEntries(_ context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
names := s.listDirectoryChildNames(dirPath, startFileName, includeStartFile, "")
|
||||
dirPrefix := string(dirPath) + "/"
|
||||
lastFileName := ""
|
||||
for i, name := range names {
|
||||
if int64(i) >= limit {
|
||||
break
|
||||
}
|
||||
entry := s.getEntry(dirPrefix + name)
|
||||
cont, err := eachEntryFunc(entry)
|
||||
if err != nil {
|
||||
return lastFileName, err
|
||||
}
|
||||
lastFileName = name
|
||||
if !cont {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastFileName, nil
|
||||
}
|
||||
func (s *stubFilerStore) ListDirectoryPrefixedEntries(_ context.Context, dirPath util.FullPath, startFileName string, includeStartFile bool, limit int64, prefix string, eachEntryFunc ListEachEntryFunc) (string, error) {
|
||||
names := s.listDirectoryChildNames(dirPath, startFileName, includeStartFile, prefix)
|
||||
dirPrefix := string(dirPath) + "/"
|
||||
lastFileName := ""
|
||||
for i, name := range names {
|
||||
if int64(i) >= limit {
|
||||
break
|
||||
}
|
||||
entry := s.getEntry(dirPrefix + name)
|
||||
cont, err := eachEntryFunc(entry)
|
||||
if err != nil {
|
||||
return lastFileName, err
|
||||
}
|
||||
lastFileName = name
|
||||
if !cont {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastFileName, nil
|
||||
}
|
||||
|
||||
func (s *stubFilerStore) InsertEntry(_ context.Context, entry *Entry) error {
|
||||
@@ -85,6 +182,9 @@ func (s *stubFilerStore) FindEntry(_ context.Context, p util.FullPath) (*Entry,
|
||||
func (s *stubFilerStore) DeleteEntry(_ context.Context, p util.FullPath) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if deleteErr, found := s.deleteErrByPath[string(p)]; found && deleteErr != nil {
|
||||
return deleteErr
|
||||
}
|
||||
delete(s.entries, string(p))
|
||||
return nil
|
||||
}
|
||||
@@ -94,6 +194,14 @@ func (s *stubFilerStore) DeleteEntry(_ context.Context, p util.FullPath) error {
|
||||
type stubRemoteClient struct {
|
||||
statResult *filer_pb.RemoteEntry
|
||||
statErr error
|
||||
deleteErr error
|
||||
removeErr error
|
||||
|
||||
deleteCalls []*remote_pb.RemoteStorageLocation
|
||||
removeCalls []*remote_pb.RemoteStorageLocation
|
||||
|
||||
listDirFn func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error
|
||||
listDirCalls int
|
||||
}
|
||||
|
||||
func (c *stubRemoteClient) StatFile(*remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
|
||||
@@ -108,17 +216,38 @@ func (c *stubRemoteClient) ReadFile(*remote_pb.RemoteStorageLocation, int64, int
|
||||
func (c *stubRemoteClient) WriteDirectory(*remote_pb.RemoteStorageLocation, *filer_pb.Entry) error {
|
||||
return nil
|
||||
}
|
||||
func (c *stubRemoteClient) RemoveDirectory(*remote_pb.RemoteStorageLocation) error { return nil }
|
||||
func (c *stubRemoteClient) RemoveDirectory(loc *remote_pb.RemoteStorageLocation) error {
|
||||
c.removeCalls = append(c.removeCalls, &remote_pb.RemoteStorageLocation{
|
||||
Name: loc.Name,
|
||||
Bucket: loc.Bucket,
|
||||
Path: loc.Path,
|
||||
})
|
||||
return c.removeErr
|
||||
}
|
||||
func (c *stubRemoteClient) WriteFile(*remote_pb.RemoteStorageLocation, *filer_pb.Entry, io.Reader) (*filer_pb.RemoteEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *stubRemoteClient) UpdateFileMetadata(*remote_pb.RemoteStorageLocation, *filer_pb.Entry, *filer_pb.Entry) error {
|
||||
return nil
|
||||
}
|
||||
func (c *stubRemoteClient) DeleteFile(*remote_pb.RemoteStorageLocation) error { return nil }
|
||||
func (c *stubRemoteClient) ListBuckets() ([]*remote_storage.Bucket, error) { return nil, nil }
|
||||
func (c *stubRemoteClient) CreateBucket(string) error { return nil }
|
||||
func (c *stubRemoteClient) DeleteBucket(string) error { return nil }
|
||||
func (c *stubRemoteClient) DeleteFile(loc *remote_pb.RemoteStorageLocation) error {
|
||||
c.deleteCalls = append(c.deleteCalls, &remote_pb.RemoteStorageLocation{
|
||||
Name: loc.Name,
|
||||
Bucket: loc.Bucket,
|
||||
Path: loc.Path,
|
||||
})
|
||||
return c.deleteErr
|
||||
}
|
||||
func (c *stubRemoteClient) ListDirectory(_ context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
c.listDirCalls++
|
||||
if c.listDirFn != nil {
|
||||
return c.listDirFn(loc, visitFn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *stubRemoteClient) ListBuckets() ([]*remote_storage.Bucket, error) { return nil, nil }
|
||||
func (c *stubRemoteClient) CreateBucket(string) error { return nil }
|
||||
func (c *stubRemoteClient) DeleteBucket(string) error { return nil }
|
||||
|
||||
// --- stub RemoteStorageClientMaker ---
|
||||
|
||||
@@ -144,9 +273,11 @@ func newTestFiler(t *testing.T, store *stubFilerStore, rs *FilerRemoteStorage) *
|
||||
f := &Filer{
|
||||
RemoteStorage: rs,
|
||||
Store: NewFilerStoreWrapper(store),
|
||||
FilerConf: NewFilerConf(),
|
||||
MaxFilenameLength: 255,
|
||||
MasterClient: mc,
|
||||
fileIdDeletionQueue: util.NewUnboundedQueue(),
|
||||
deletionQuit: make(chan struct{}),
|
||||
LocalMetaLogBuffer: log_buffer.NewLogBuffer("test", time.Minute,
|
||||
func(*log_buffer.LogBuffer, time.Time, time.Time, []byte, int64, int64) {}, nil, func() {}),
|
||||
}
|
||||
@@ -368,3 +499,603 @@ func TestFindEntry_LazyFetchOnMiss(t *testing.T) {
|
||||
require.NotNil(t, entry2)
|
||||
assert.Equal(t, uint64(999), entry2.FileSize)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteOnlyFileDeletesRemoteAndMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_file"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "deletestore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "deletestore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/file.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 64,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 64},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
require.Len(t, stub.deleteCalls, 1)
|
||||
assert.Equal(t, "deletestore", stub.deleteCalls[0].Name)
|
||||
assert.Equal(t, "mybucket", stub.deleteCalls[0].Bucket)
|
||||
assert.Equal(t, "/file.txt", stub.deleteCalls[0].Path)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_IsFromOtherClusterSkipsRemoteDelete(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_other_cluster"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "othercluster", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "othercluster",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/replicated.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 64,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 64},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
// isFromOtherCluster=true simulates a replicated delete from another filer
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, true, nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Local metadata should be deleted
|
||||
_, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
// Remote should NOT have been called — the originating filer handles that
|
||||
require.Len(t, stub.deleteCalls, 0)
|
||||
require.Len(t, stub.removeCalls, 0)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteOnlyFileNotUnderMountSkipsRemoteDelete(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_not_under_mount"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "notundermount", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "notundermount",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/no/mount/file.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 99,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 99},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stub.deleteCalls, 0)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteMountWithoutClientResolutionKeepsMetadata(t *testing.T) {
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf["missingclient"] = &remote_pb.RemoteConf{Name: "missingclient", Type: "stub_missing_client"}
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "missingclient",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/no-client.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 51,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 51},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "resolve remote storage client")
|
||||
require.ErrorContains(t, err, string(filePath))
|
||||
|
||||
stored, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, stored)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_LocalDeleteFailurePreservesMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_local_fail"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "localfail", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "localfail",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/localfail.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 80,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 80},
|
||||
}
|
||||
store.deleteErrByPath[string(filePath)] = errors.New("simulated local delete failure")
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "filer store delete")
|
||||
require.Len(t, stub.deleteCalls, 1)
|
||||
|
||||
// Local metadata should still exist since local delete failed
|
||||
stored, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, stored)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteDeleteNotFoundStillDeletesMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_notfound"
|
||||
stub := &stubRemoteClient{deleteErr: remote_storage.ErrRemoteObjectNotFound}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "deletenotfound", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "deletenotfound",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/notfound.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 23,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 23},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
_, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RemoteDeleteErrorKeepsMetadata(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_error"
|
||||
stub := &stubRemoteClient{deleteErr: errors.New("remote delete failed")}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "deleteerr", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "deleteerr",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
filePath := util.FullPath("/buckets/mybucket/error.txt")
|
||||
store.entries[string(filePath)] = &Entry{
|
||||
FullPath: filePath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 77,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 77},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)
|
||||
require.Error(t, err)
|
||||
stored, findErr := store.FindEntry(context.Background(), filePath)
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, stored)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_DirectoryUnderMountDeletesRemoteDirectory(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_dir"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "dirstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "dirstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
dirPath := util.FullPath("/buckets/mybucket/dir")
|
||||
store.entries[string(dirPath)] = &Entry{
|
||||
FullPath: dirPath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: os.ModeDir | 0755,
|
||||
},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.doDeleteEntryMetaAndData(context.Background(), store.entries[string(dirPath)], false, false, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, stub.removeCalls, 1)
|
||||
assert.Equal(t, "dirstore", stub.removeCalls[0].Name)
|
||||
assert.Equal(t, "mybucket", stub.removeCalls[0].Bucket)
|
||||
assert.Equal(t, "/dir", stub.removeCalls[0].Path)
|
||||
_, findErr := store.FindEntry(context.Background(), dirPath)
|
||||
require.ErrorIs(t, findErr, filer_pb.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteEntryMetaAndData_RecursiveFolderDeleteRemotesChildren(t *testing.T) {
|
||||
const storageType = "stub_lazy_delete_folder_children"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "childstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "childstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
dirPath := util.FullPath("/buckets/mybucket/subdir")
|
||||
store.entries[string(dirPath)] = &Entry{
|
||||
FullPath: dirPath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: os.ModeDir | 0755,
|
||||
},
|
||||
}
|
||||
childPath := util.FullPath("/buckets/mybucket/subdir/child.txt")
|
||||
store.entries[string(childPath)] = &Entry{
|
||||
FullPath: childPath,
|
||||
Attr: Attr{
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1700000000, 0),
|
||||
Mode: 0644,
|
||||
FileSize: 50,
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 50},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
err := f.DeleteEntryMetaAndData(context.Background(), dirPath, true, false, false, false, nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Child file should have been deleted from remote
|
||||
require.Len(t, stub.deleteCalls, 1)
|
||||
assert.Equal(t, "/subdir/child.txt", stub.deleteCalls[0].Path)
|
||||
// Directory itself should also have been deleted from remote
|
||||
require.Len(t, stub.removeCalls, 1)
|
||||
assert.Equal(t, "/subdir", stub.removeCalls[0].Path)
|
||||
}
|
||||
|
||||
// --- lazy listing tests ---
|
||||
|
||||
func TestMaybeLazyListFromRemote_PopulatesStoreFromRemote(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_populate"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
if err := visitFn("/", "subdir", true, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visitFn("/", "file.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000,
|
||||
RemoteSize: 42,
|
||||
RemoteETag: "abc",
|
||||
StorageName: "myliststore",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "myliststore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "myliststore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls)
|
||||
|
||||
// Check that the file was persisted
|
||||
fileEntry := store.getEntry("/buckets/mybucket/file.txt")
|
||||
require.NotNil(t, fileEntry, "file.txt should be persisted")
|
||||
assert.Equal(t, uint64(42), fileEntry.FileSize)
|
||||
assert.NotNil(t, fileEntry.Remote)
|
||||
|
||||
// Check that the subdirectory was persisted
|
||||
dirEntry := store.getEntry("/buckets/mybucket/subdir")
|
||||
require.NotNil(t, dirEntry, "subdir should be persisted")
|
||||
assert.True(t, dirEntry.IsDirectory())
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_DisabledWhenTTLZero(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_disabled"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "file.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000, RemoteSize: 10,
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "disabledstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "disabledstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
// ListingCacheTtlSeconds defaults to 0 → disabled
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 0, stub.listDirCalls, "should not call remote when TTL is 0")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_TTLCachePreventsSecondCall(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_ttl"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "file.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000, RemoteSize: 10,
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "ttlstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "ttlstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
// First call should hit remote
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls)
|
||||
|
||||
// Second call within TTL should be a no-op
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls, "should not call remote again within TTL")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_NotUnderMount(t *testing.T) {
|
||||
rs := NewFilerRemoteStorage()
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/not/a/mount"))
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_SkipsLocalOnlyEntries(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_skiplocal"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
// Remote has a file called "local.txt" too
|
||||
return visitFn("/", "local.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000, RemoteSize: 99,
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "skipstore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "skipstore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
// Pre-populate a local-only entry (no Remote field)
|
||||
store.entries["/buckets/mybucket/local.txt"] = &Entry{
|
||||
FullPath: "/buckets/mybucket/local.txt",
|
||||
Attr: Attr{Mode: 0644, FileSize: 50},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
|
||||
// Local entry should NOT have been overwritten
|
||||
localEntry := store.getEntry("/buckets/mybucket/local.txt")
|
||||
require.NotNil(t, localEntry)
|
||||
assert.Equal(t, uint64(50), localEntry.FileSize, "local-only entry should not be overwritten")
|
||||
assert.Nil(t, localEntry.Remote, "local-only entry should keep nil Remote")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_MergesExistingRemoteEntry(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_merge"
|
||||
stub := &stubRemoteClient{
|
||||
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
return visitFn("/", "cached.txt", false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000099, // updated mtime
|
||||
RemoteSize: 200, // updated size
|
||||
RemoteETag: "new-etag",
|
||||
StorageName: "mergestore",
|
||||
})
|
||||
},
|
||||
}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "mergestore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "mergestore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
// Pre-populate an existing remote-backed entry with chunks and extended attrs
|
||||
existingChunks := []*filer_pb.FileChunk{
|
||||
{FileId: "1,abc123", Size: 100, Offset: 0},
|
||||
}
|
||||
store.entries["/buckets/mybucket/cached.txt"] = &Entry{
|
||||
FullPath: "/buckets/mybucket/cached.txt",
|
||||
Attr: Attr{
|
||||
Mode: 0644,
|
||||
FileSize: 100,
|
||||
Uid: 1000,
|
||||
Gid: 1000,
|
||||
Mtime: time.Unix(1700000000, 0),
|
||||
Crtime: time.Unix(1699000000, 0),
|
||||
},
|
||||
Chunks: existingChunks,
|
||||
Extended: map[string][]byte{
|
||||
"user.custom": []byte("myvalue"),
|
||||
},
|
||||
Remote: &filer_pb.RemoteEntry{
|
||||
RemoteMtime: 1700000000,
|
||||
RemoteSize: 100,
|
||||
RemoteETag: "old-etag",
|
||||
StorageName: "mergestore",
|
||||
},
|
||||
}
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 1, stub.listDirCalls)
|
||||
|
||||
merged := store.getEntry("/buckets/mybucket/cached.txt")
|
||||
require.NotNil(t, merged)
|
||||
|
||||
// Remote metadata should be updated
|
||||
assert.Equal(t, int64(1700000099), merged.Remote.RemoteMtime)
|
||||
assert.Equal(t, int64(200), merged.Remote.RemoteSize)
|
||||
assert.Equal(t, "new-etag", merged.Remote.RemoteETag)
|
||||
assert.Equal(t, uint64(200), merged.FileSize)
|
||||
assert.Equal(t, time.Unix(1700000099, 0), merged.Mtime)
|
||||
|
||||
// Local state should be preserved
|
||||
assert.Equal(t, existingChunks, merged.Chunks, "chunks must be preserved")
|
||||
assert.Equal(t, []byte("myvalue"), merged.Extended["user.custom"], "extended attrs must be preserved")
|
||||
assert.Equal(t, uint32(1000), merged.Uid, "uid must be preserved")
|
||||
assert.Equal(t, uint32(1000), merged.Gid, "gid must be preserved")
|
||||
assert.Equal(t, os.FileMode(0644), merged.Mode, "mode must be preserved")
|
||||
assert.Equal(t, time.Unix(1699000000, 0), merged.Crtime, "crtime must be preserved")
|
||||
}
|
||||
|
||||
func TestMaybeLazyListFromRemote_ContextGuardPreventsRecursion(t *testing.T) {
|
||||
const storageType = "stub_lazy_list_guard"
|
||||
stub := &stubRemoteClient{}
|
||||
defer registerStubMaker(t, storageType, stub)()
|
||||
|
||||
conf := &remote_pb.RemoteConf{Name: "guardliststore", Type: storageType}
|
||||
rs := NewFilerRemoteStorage()
|
||||
rs.storageNameToConf[conf.Name] = conf
|
||||
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
|
||||
Name: "guardliststore",
|
||||
Bucket: "mybucket",
|
||||
Path: "/",
|
||||
ListingCacheTtlSeconds: 300,
|
||||
})
|
||||
|
||||
store := newStubFilerStore()
|
||||
f := newTestFiler(t, store, rs)
|
||||
|
||||
// With lazyListContextKey set, should be a no-op
|
||||
guardCtx := context.WithValue(context.Background(), lazyListContextKey{}, true)
|
||||
f.maybeLazyListFromRemote(guardCtx, util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 0, stub.listDirCalls)
|
||||
|
||||
// With lazyFetchContextKey set, should also be a no-op
|
||||
fetchCtx := context.WithValue(context.Background(), lazyFetchContextKey{}, true)
|
||||
f.maybeLazyListFromRemote(fetchCtx, util.FullPath("/buckets/mybucket"))
|
||||
assert.Equal(t, 0, stub.listDirCalls)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ func TestFilerRemoteStorage_FindMountDirectory_LongestPrefixWins(t *testing.T) {
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantMount string
|
||||
wantBucket string
|
||||
path string
|
||||
wantMount string
|
||||
wantBucket string
|
||||
}{
|
||||
{"/buckets/mybucket/file.txt", "/buckets/mybucket", "bucket-root"},
|
||||
{"/buckets/mybucket/prefix/file.txt", "/buckets/mybucket/prefix", "bucket-prefix"},
|
||||
|
||||
@@ -22,13 +22,13 @@ func TestTrustPolicyAWSUserPrincipal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
backendUser = "backend"
|
||||
backendArn = "arn:aws:iam::" + accountID + ":user/" + backendUser
|
||||
otherUser = "other"
|
||||
otherArn = "arn:aws:iam::" + accountID + ":user/" + otherUser
|
||||
clientRoleN = "ClientRole"
|
||||
clientRoleA = "arn:aws:iam::role/" + clientRoleN
|
||||
accountID = "000000000000"
|
||||
backendUser = "backend"
|
||||
backendArn = "arn:aws:iam::" + accountID + ":user/" + backendUser
|
||||
otherUser = "other"
|
||||
otherArn = "arn:aws:iam::" + accountID + ":user/" + otherUser
|
||||
clientRoleN = "ClientRole"
|
||||
clientRoleA = "arn:aws:iam::role/" + clientRoleN
|
||||
)
|
||||
|
||||
// Create role with trust policy restricted to a specific AWS user principal
|
||||
|
||||
@@ -362,7 +362,7 @@ type ListAttachedGroupPoliciesResponse struct {
|
||||
|
||||
// ListGroupsForUserResponse is the response for ListGroupsForUser action.
|
||||
type ListGroupsForUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"`
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"`
|
||||
ListGroupsForUserResult struct {
|
||||
Groups []*iam.Group `xml:"Groups>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
|
||||
@@ -326,4 +326,3 @@ func buildUserGroupsIndex(s3cfg *iam_pb.S3ApiConfiguration) map[string][]string
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
|
||||
@@ -9,19 +9,19 @@ import (
|
||||
|
||||
// Type aliases for IAM response types from shared package
|
||||
type (
|
||||
CommonResponse = iamlib.CommonResponse
|
||||
ListUsersResponse = iamlib.ListUsersResponse
|
||||
ListAccessKeysResponse = iamlib.ListAccessKeysResponse
|
||||
DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse
|
||||
CreatePolicyResponse = iamlib.CreatePolicyResponse
|
||||
CreateUserResponse = iamlib.CreateUserResponse
|
||||
DeleteUserResponse = iamlib.DeleteUserResponse
|
||||
GetUserResponse = iamlib.GetUserResponse
|
||||
UpdateUserResponse = iamlib.UpdateUserResponse
|
||||
CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse
|
||||
UpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse
|
||||
PutUserPolicyResponse = iamlib.PutUserPolicyResponse
|
||||
DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse
|
||||
CommonResponse = iamlib.CommonResponse
|
||||
ListUsersResponse = iamlib.ListUsersResponse
|
||||
ListAccessKeysResponse = iamlib.ListAccessKeysResponse
|
||||
DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse
|
||||
CreatePolicyResponse = iamlib.CreatePolicyResponse
|
||||
CreateUserResponse = iamlib.CreateUserResponse
|
||||
DeleteUserResponse = iamlib.DeleteUserResponse
|
||||
GetUserResponse = iamlib.GetUserResponse
|
||||
UpdateUserResponse = iamlib.UpdateUserResponse
|
||||
CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse
|
||||
UpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse
|
||||
PutUserPolicyResponse = iamlib.PutUserPolicyResponse
|
||||
DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse
|
||||
GetUserPolicyResponse = iamlib.GetUserPolicyResponse
|
||||
GetPolicyResponse = iamlib.GetPolicyResponse
|
||||
DeletePolicyResponse = iamlib.DeletePolicyResponse
|
||||
@@ -30,12 +30,12 @@ type (
|
||||
DetachUserPolicyResponse = iamlib.DetachUserPolicyResponse
|
||||
ListAttachedUserPoliciesResponse = iamlib.ListAttachedUserPoliciesResponse
|
||||
ErrorResponse = iamlib.ErrorResponse
|
||||
ServiceAccountInfo = iamlib.ServiceAccountInfo
|
||||
CreateServiceAccountResponse = iamlib.CreateServiceAccountResponse
|
||||
DeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse
|
||||
ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse
|
||||
GetServiceAccountResponse = iamlib.GetServiceAccountResponse
|
||||
UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse
|
||||
ServiceAccountInfo = iamlib.ServiceAccountInfo
|
||||
CreateServiceAccountResponse = iamlib.CreateServiceAccountResponse
|
||||
DeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse
|
||||
ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse
|
||||
GetServiceAccountResponse = iamlib.GetServiceAccountResponse
|
||||
UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse
|
||||
// Group response types
|
||||
CreateGroupResponse = iamlib.CreateGroupResponse
|
||||
DeleteGroupResponse = iamlib.DeleteGroupResponse
|
||||
|
||||
@@ -37,8 +37,8 @@ type MetaCache struct {
|
||||
applyDone chan struct{}
|
||||
applyStateMu sync.Mutex
|
||||
applyClosed bool
|
||||
buildingDirs map[util.FullPath]*directoryBuildState
|
||||
dedupRing dedupRingBuffer
|
||||
buildingDirs map[util.FullPath]*directoryBuildState
|
||||
dedupRing dedupRingBuffer
|
||||
}
|
||||
|
||||
var errMetaCacheClosed = errors.New("metadata cache is shut down")
|
||||
@@ -98,8 +98,8 @@ func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPat
|
||||
invalidateFunc: func(fullpath util.FullPath, entry *filer_pb.Entry) {
|
||||
invalidateFunc(fullpath, entry)
|
||||
},
|
||||
applyCh: make(chan metadataApplyRequest, 128),
|
||||
applyDone: make(chan struct{}),
|
||||
applyCh: make(chan metadataApplyRequest, 128),
|
||||
applyDone: make(chan struct{}),
|
||||
buildingDirs: make(map[util.FullPath]*directoryBuildState),
|
||||
dedupRing: newDedupRingBuffer(),
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ func (c *RDMAMountClient) ReadNeedle(ctx context.Context, fileID string, offset,
|
||||
|
||||
var data []byte
|
||||
|
||||
var n int
|
||||
if useTempFile && tempFilePath != "" {
|
||||
// Zero-copy path: read from temp file (page cache)
|
||||
glog.V(4).Infof("🔥 Using zero-copy temp file: %s", tempFilePath)
|
||||
@@ -237,7 +238,7 @@ func (c *RDMAMountClient) ReadNeedle(ctx context.Context, fileID string, offset,
|
||||
}
|
||||
buffer := make([]byte, bufferSize)
|
||||
|
||||
n, err := c.readFromTempFile(tempFilePath, buffer)
|
||||
n, err = c.readFromTempFile(tempFilePath, buffer)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Zero-copy failed, falling back to HTTP body: %v", err)
|
||||
// Fall back to reading HTTP body
|
||||
|
||||
@@ -350,11 +350,13 @@ message VacuumVolumeResponse {
|
||||
}
|
||||
|
||||
message DisableVacuumRequest {
|
||||
bool by_plugin = 1;
|
||||
}
|
||||
message DisableVacuumResponse {
|
||||
}
|
||||
|
||||
message EnableVacuumRequest {
|
||||
bool by_plugin = 1;
|
||||
}
|
||||
message EnableVacuumResponse {
|
||||
}
|
||||
|
||||
@@ -2650,6 +2650,7 @@ func (*VacuumVolumeResponse) Descriptor() ([]byte, []int) {
|
||||
|
||||
type DisableVacuumRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ByPlugin bool `protobuf:"varint,1,opt,name=by_plugin,json=byPlugin,proto3" json:"by_plugin,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2684,6 +2685,13 @@ func (*DisableVacuumRequest) Descriptor() ([]byte, []int) {
|
||||
return file_master_proto_rawDescGZIP(), []int{37}
|
||||
}
|
||||
|
||||
func (x *DisableVacuumRequest) GetByPlugin() bool {
|
||||
if x != nil {
|
||||
return x.ByPlugin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type DisableVacuumResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -2722,6 +2730,7 @@ func (*DisableVacuumResponse) Descriptor() ([]byte, []int) {
|
||||
|
||||
type EnableVacuumRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ByPlugin bool `protobuf:"varint,1,opt,name=by_plugin,json=byPlugin,proto3" json:"by_plugin,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2756,6 +2765,13 @@ func (*EnableVacuumRequest) Descriptor() ([]byte, []int) {
|
||||
return file_master_proto_rawDescGZIP(), []int{39}
|
||||
}
|
||||
|
||||
func (x *EnableVacuumRequest) GetByPlugin() bool {
|
||||
if x != nil {
|
||||
return x.ByPlugin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EnableVacuumResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -2973,16 +2989,17 @@ func (*GetMasterConfigurationRequest) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type GetMasterConfigurationResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MetricsAddress string `protobuf:"bytes,1,opt,name=metrics_address,json=metricsAddress,proto3" json:"metrics_address,omitempty"`
|
||||
MetricsIntervalSeconds uint32 `protobuf:"varint,2,opt,name=metrics_interval_seconds,json=metricsIntervalSeconds,proto3" json:"metrics_interval_seconds,omitempty"`
|
||||
StorageBackends []*StorageBackend `protobuf:"bytes,3,rep,name=storage_backends,json=storageBackends,proto3" json:"storage_backends,omitempty"`
|
||||
DefaultReplication string `protobuf:"bytes,4,opt,name=default_replication,json=defaultReplication,proto3" json:"default_replication,omitempty"`
|
||||
Leader string `protobuf:"bytes,5,opt,name=leader,proto3" json:"leader,omitempty"`
|
||||
VolumeSizeLimitMB uint32 `protobuf:"varint,6,opt,name=volume_size_limit_m_b,json=volumeSizeLimitMB,proto3" json:"volume_size_limit_m_b,omitempty"`
|
||||
VolumePreallocate bool `protobuf:"varint,7,opt,name=volume_preallocate,json=volumePreallocate,proto3" json:"volume_preallocate,omitempty"`
|
||||
MaintenanceScripts string `protobuf:"bytes,8,opt,name=maintenance_scripts,json=maintenanceScripts,proto3" json:"maintenance_scripts,omitempty"`
|
||||
MaintenanceSleepMinutes uint32 `protobuf:"varint,9,opt,name=maintenance_sleep_minutes,json=maintenanceSleepMinutes,proto3" json:"maintenance_sleep_minutes,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MetricsAddress string `protobuf:"bytes,1,opt,name=metrics_address,json=metricsAddress,proto3" json:"metrics_address,omitempty"`
|
||||
MetricsIntervalSeconds uint32 `protobuf:"varint,2,opt,name=metrics_interval_seconds,json=metricsIntervalSeconds,proto3" json:"metrics_interval_seconds,omitempty"`
|
||||
StorageBackends []*StorageBackend `protobuf:"bytes,3,rep,name=storage_backends,json=storageBackends,proto3" json:"storage_backends,omitempty"`
|
||||
DefaultReplication string `protobuf:"bytes,4,opt,name=default_replication,json=defaultReplication,proto3" json:"default_replication,omitempty"`
|
||||
Leader string `protobuf:"bytes,5,opt,name=leader,proto3" json:"leader,omitempty"`
|
||||
VolumeSizeLimitMB uint32 `protobuf:"varint,6,opt,name=volume_size_limit_m_b,json=volumeSizeLimitMB,proto3" json:"volume_size_limit_m_b,omitempty"`
|
||||
VolumePreallocate bool `protobuf:"varint,7,opt,name=volume_preallocate,json=volumePreallocate,proto3" json:"volume_preallocate,omitempty"`
|
||||
// MIGRATION: fields 8-9 help migrate master.toml [master.maintenance] to admin script plugin. Remove after March 2027.
|
||||
MaintenanceScripts string `protobuf:"bytes,8,opt,name=maintenance_scripts,json=maintenanceScripts,proto3" json:"maintenance_scripts,omitempty"`
|
||||
MaintenanceSleepMinutes uint32 `protobuf:"varint,9,opt,name=maintenance_sleep_minutes,json=maintenanceSleepMinutes,proto3" json:"maintenance_sleep_minutes,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4525,10 +4542,12 @@ const file_master_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"collection\x18\x03 \x01(\tR\n" +
|
||||
"collection\"\x16\n" +
|
||||
"\x14VacuumVolumeResponse\"\x16\n" +
|
||||
"\x14DisableVacuumRequest\"\x17\n" +
|
||||
"\x15DisableVacuumResponse\"\x15\n" +
|
||||
"\x13EnableVacuumRequest\"\x16\n" +
|
||||
"\x14VacuumVolumeResponse\"3\n" +
|
||||
"\x14DisableVacuumRequest\x12\x1b\n" +
|
||||
"\tby_plugin\x18\x01 \x01(\bR\bbyPlugin\"\x17\n" +
|
||||
"\x15DisableVacuumResponse\"2\n" +
|
||||
"\x13EnableVacuumRequest\x12\x1b\n" +
|
||||
"\tby_plugin\x18\x01 \x01(\bR\bbyPlugin\"\x16\n" +
|
||||
"\x14EnableVacuumResponse\"\x93\x02\n" +
|
||||
"\x19VolumeMarkReadonlyRequest\x12\x0e\n" +
|
||||
"\x02ip\x18\x01 \x01(\tR\x02ip\x12\x12\n" +
|
||||
|
||||
@@ -73,4 +73,5 @@ message RemoteStorageLocation {
|
||||
string name = 1;
|
||||
string bucket = 2;
|
||||
string path = 3;
|
||||
int32 listing_cache_ttl_seconds = 4; // 0 = disabled; >0 enables on-demand directory listing with this TTL in seconds
|
||||
}
|
||||
|
||||
@@ -457,12 +457,13 @@ func (x *RemoteStorageMapping) GetPrimaryBucketStorageName() string {
|
||||
}
|
||||
|
||||
type RemoteStorageLocation struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Bucket string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
|
||||
Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Bucket string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
|
||||
Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
|
||||
ListingCacheTtlSeconds int32 `protobuf:"varint,4,opt,name=listing_cache_ttl_seconds,json=listingCacheTtlSeconds,proto3" json:"listing_cache_ttl_seconds,omitempty"` // 0 = disabled; >0 enables on-demand directory listing with this TTL in seconds
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RemoteStorageLocation) Reset() {
|
||||
@@ -516,6 +517,13 @@ func (x *RemoteStorageLocation) GetPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RemoteStorageLocation) GetListingCacheTtlSeconds() int32 {
|
||||
if x != nil {
|
||||
return x.ListingCacheTtlSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var File_remote_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_remote_proto_rawDesc = "" +
|
||||
@@ -573,11 +581,12 @@ const file_remote_proto_rawDesc = "" +
|
||||
"\x1bprimary_bucket_storage_name\x18\x02 \x01(\tR\x18primaryBucketStorageName\x1a]\n" +
|
||||
"\rMappingsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x126\n" +
|
||||
"\x05value\x18\x02 \x01(\v2 .remote_pb.RemoteStorageLocationR\x05value:\x028\x01\"W\n" +
|
||||
"\x05value\x18\x02 \x01(\v2 .remote_pb.RemoteStorageLocationR\x05value:\x028\x01\"\x92\x01\n" +
|
||||
"\x15RemoteStorageLocation\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06bucket\x18\x02 \x01(\tR\x06bucket\x12\x12\n" +
|
||||
"\x04path\x18\x03 \x01(\tR\x04pathBP\n" +
|
||||
"\x04path\x18\x03 \x01(\tR\x04path\x129\n" +
|
||||
"\x19listing_cache_ttl_seconds\x18\x04 \x01(\x05R\x16listingCacheTtlSecondsBP\n" +
|
||||
"\x10seaweedfs.clientB\n" +
|
||||
"FilerProtoZ0github.com/seaweedfs/seaweedfs/weed/pb/remote_pbb\x06proto3"
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ message TaskParams {
|
||||
ErasureCodingTaskParams erasure_coding_params = 10;
|
||||
BalanceTaskParams balance_params = 11;
|
||||
ReplicationTaskParams replication_params = 12;
|
||||
EcBalanceTaskParams ec_balance_params = 13;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +308,7 @@ message TaskPolicy {
|
||||
ErasureCodingTaskConfig erasure_coding_config = 6;
|
||||
BalanceTaskConfig balance_config = 7;
|
||||
ReplicationTaskConfig replication_config = 8;
|
||||
EcBalanceTaskConfig ec_balance_config = 9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +341,34 @@ message ReplicationTaskConfig {
|
||||
int32 target_replica_count = 1; // Target number of replicas
|
||||
}
|
||||
|
||||
// EcBalanceTaskParams for EC shard balancing operations
|
||||
message EcBalanceTaskParams {
|
||||
string disk_type = 1; // Disk type filter (hdd, ssd, "")
|
||||
int32 max_parallelization = 2; // Max parallel shard moves within a batch
|
||||
int32 timeout_seconds = 3; // Operation timeout per move
|
||||
repeated EcShardMoveSpec moves = 4; // Batch: multiple shard moves in one job
|
||||
}
|
||||
|
||||
// EcShardMoveSpec describes a single EC shard move within a batch
|
||||
message EcShardMoveSpec {
|
||||
uint32 volume_id = 1; // EC volume ID
|
||||
uint32 shard_id = 2; // Shard ID (0-13)
|
||||
string collection = 3; // Collection name
|
||||
string source_node = 4; // Source server address
|
||||
uint32 source_disk_id = 5; // Source disk ID
|
||||
string target_node = 6; // Target server address
|
||||
uint32 target_disk_id = 7; // Target disk ID
|
||||
}
|
||||
|
||||
// EcBalanceTaskConfig contains EC balance-specific configuration
|
||||
message EcBalanceTaskConfig {
|
||||
double imbalance_threshold = 1; // Threshold for triggering EC shard rebalancing
|
||||
int32 min_server_count = 2; // Minimum number of servers required
|
||||
string collection_filter = 3; // Collection filter
|
||||
string disk_type = 4; // Disk type filter
|
||||
repeated string preferred_tags = 5; // Preferred disk tags for placement
|
||||
}
|
||||
|
||||
// ========== Task Persistence Messages ==========
|
||||
|
||||
// MaintenanceTaskData represents complete task state for persistence
|
||||
|
||||
+370
-66
@@ -821,6 +821,7 @@ type TaskParams struct {
|
||||
// *TaskParams_ErasureCodingParams
|
||||
// *TaskParams_BalanceParams
|
||||
// *TaskParams_ReplicationParams
|
||||
// *TaskParams_EcBalanceParams
|
||||
TaskParams isTaskParams_TaskParams `protobuf_oneof:"task_params"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -955,6 +956,15 @@ func (x *TaskParams) GetReplicationParams() *ReplicationTaskParams {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TaskParams) GetEcBalanceParams() *EcBalanceTaskParams {
|
||||
if x != nil {
|
||||
if x, ok := x.TaskParams.(*TaskParams_EcBalanceParams); ok {
|
||||
return x.EcBalanceParams
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isTaskParams_TaskParams interface {
|
||||
isTaskParams_TaskParams()
|
||||
}
|
||||
@@ -975,6 +985,10 @@ type TaskParams_ReplicationParams struct {
|
||||
ReplicationParams *ReplicationTaskParams `protobuf:"bytes,12,opt,name=replication_params,json=replicationParams,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TaskParams_EcBalanceParams struct {
|
||||
EcBalanceParams *EcBalanceTaskParams `protobuf:"bytes,13,opt,name=ec_balance_params,json=ecBalanceParams,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*TaskParams_VacuumParams) isTaskParams_TaskParams() {}
|
||||
|
||||
func (*TaskParams_ErasureCodingParams) isTaskParams_TaskParams() {}
|
||||
@@ -983,6 +997,8 @@ func (*TaskParams_BalanceParams) isTaskParams_TaskParams() {}
|
||||
|
||||
func (*TaskParams_ReplicationParams) isTaskParams_TaskParams() {}
|
||||
|
||||
func (*TaskParams_EcBalanceParams) isTaskParams_TaskParams() {}
|
||||
|
||||
// VacuumTaskParams for vacuum operations
|
||||
type VacuumTaskParams struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2480,6 +2496,7 @@ type TaskPolicy struct {
|
||||
// *TaskPolicy_ErasureCodingConfig
|
||||
// *TaskPolicy_BalanceConfig
|
||||
// *TaskPolicy_ReplicationConfig
|
||||
// *TaskPolicy_EcBalanceConfig
|
||||
TaskConfig isTaskPolicy_TaskConfig `protobuf_oneof:"task_config"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -2586,6 +2603,15 @@ func (x *TaskPolicy) GetReplicationConfig() *ReplicationTaskConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TaskPolicy) GetEcBalanceConfig() *EcBalanceTaskConfig {
|
||||
if x != nil {
|
||||
if x, ok := x.TaskConfig.(*TaskPolicy_EcBalanceConfig); ok {
|
||||
return x.EcBalanceConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isTaskPolicy_TaskConfig interface {
|
||||
isTaskPolicy_TaskConfig()
|
||||
}
|
||||
@@ -2606,6 +2632,10 @@ type TaskPolicy_ReplicationConfig struct {
|
||||
ReplicationConfig *ReplicationTaskConfig `protobuf:"bytes,8,opt,name=replication_config,json=replicationConfig,proto3,oneof"`
|
||||
}
|
||||
|
||||
type TaskPolicy_EcBalanceConfig struct {
|
||||
EcBalanceConfig *EcBalanceTaskConfig `protobuf:"bytes,9,opt,name=ec_balance_config,json=ecBalanceConfig,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*TaskPolicy_VacuumConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
func (*TaskPolicy_ErasureCodingConfig) isTaskPolicy_TaskConfig() {}
|
||||
@@ -2614,6 +2644,8 @@ func (*TaskPolicy_BalanceConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
func (*TaskPolicy_ReplicationConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
func (*TaskPolicy_EcBalanceConfig) isTaskPolicy_TaskConfig() {}
|
||||
|
||||
// VacuumTaskConfig contains vacuum-specific configuration
|
||||
type VacuumTaskConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2850,6 +2882,245 @@ func (x *ReplicationTaskConfig) GetTargetReplicaCount() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// EcBalanceTaskParams for EC shard balancing operations
|
||||
type EcBalanceTaskParams struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DiskType string `protobuf:"bytes,1,opt,name=disk_type,json=diskType,proto3" json:"disk_type,omitempty"` // Disk type filter (hdd, ssd, "")
|
||||
MaxParallelization int32 `protobuf:"varint,2,opt,name=max_parallelization,json=maxParallelization,proto3" json:"max_parallelization,omitempty"` // Max parallel shard moves within a batch
|
||||
TimeoutSeconds int32 `protobuf:"varint,3,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` // Operation timeout per move
|
||||
Moves []*EcShardMoveSpec `protobuf:"bytes,4,rep,name=moves,proto3" json:"moves,omitempty"` // Batch: multiple shard moves in one job
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) Reset() {
|
||||
*x = EcBalanceTaskParams{}
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EcBalanceTaskParams) ProtoMessage() {}
|
||||
|
||||
func (x *EcBalanceTaskParams) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EcBalanceTaskParams.ProtoReflect.Descriptor instead.
|
||||
func (*EcBalanceTaskParams) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{32}
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetDiskType() string {
|
||||
if x != nil {
|
||||
return x.DiskType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetMaxParallelization() int32 {
|
||||
if x != nil {
|
||||
return x.MaxParallelization
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetTimeoutSeconds() int32 {
|
||||
if x != nil {
|
||||
return x.TimeoutSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskParams) GetMoves() []*EcShardMoveSpec {
|
||||
if x != nil {
|
||||
return x.Moves
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EcShardMoveSpec describes a single EC shard move within a batch
|
||||
type EcShardMoveSpec struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
VolumeId uint32 `protobuf:"varint,1,opt,name=volume_id,json=volumeId,proto3" json:"volume_id,omitempty"` // EC volume ID
|
||||
ShardId uint32 `protobuf:"varint,2,opt,name=shard_id,json=shardId,proto3" json:"shard_id,omitempty"` // Shard ID (0-13)
|
||||
Collection string `protobuf:"bytes,3,opt,name=collection,proto3" json:"collection,omitempty"` // Collection name
|
||||
SourceNode string `protobuf:"bytes,4,opt,name=source_node,json=sourceNode,proto3" json:"source_node,omitempty"` // Source server address
|
||||
SourceDiskId uint32 `protobuf:"varint,5,opt,name=source_disk_id,json=sourceDiskId,proto3" json:"source_disk_id,omitempty"` // Source disk ID
|
||||
TargetNode string `protobuf:"bytes,6,opt,name=target_node,json=targetNode,proto3" json:"target_node,omitempty"` // Target server address
|
||||
TargetDiskId uint32 `protobuf:"varint,7,opt,name=target_disk_id,json=targetDiskId,proto3" json:"target_disk_id,omitempty"` // Target disk ID
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) Reset() {
|
||||
*x = EcShardMoveSpec{}
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EcShardMoveSpec) ProtoMessage() {}
|
||||
|
||||
func (x *EcShardMoveSpec) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EcShardMoveSpec.ProtoReflect.Descriptor instead.
|
||||
func (*EcShardMoveSpec) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{33}
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetVolumeId() uint32 {
|
||||
if x != nil {
|
||||
return x.VolumeId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetShardId() uint32 {
|
||||
if x != nil {
|
||||
return x.ShardId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetCollection() string {
|
||||
if x != nil {
|
||||
return x.Collection
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetSourceNode() string {
|
||||
if x != nil {
|
||||
return x.SourceNode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetSourceDiskId() uint32 {
|
||||
if x != nil {
|
||||
return x.SourceDiskId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetTargetNode() string {
|
||||
if x != nil {
|
||||
return x.TargetNode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcShardMoveSpec) GetTargetDiskId() uint32 {
|
||||
if x != nil {
|
||||
return x.TargetDiskId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// EcBalanceTaskConfig contains EC balance-specific configuration
|
||||
type EcBalanceTaskConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ImbalanceThreshold float64 `protobuf:"fixed64,1,opt,name=imbalance_threshold,json=imbalanceThreshold,proto3" json:"imbalance_threshold,omitempty"` // Threshold for triggering EC shard rebalancing
|
||||
MinServerCount int32 `protobuf:"varint,2,opt,name=min_server_count,json=minServerCount,proto3" json:"min_server_count,omitempty"` // Minimum number of servers required
|
||||
CollectionFilter string `protobuf:"bytes,3,opt,name=collection_filter,json=collectionFilter,proto3" json:"collection_filter,omitempty"` // Collection filter
|
||||
DiskType string `protobuf:"bytes,4,opt,name=disk_type,json=diskType,proto3" json:"disk_type,omitempty"` // Disk type filter
|
||||
PreferredTags []string `protobuf:"bytes,5,rep,name=preferred_tags,json=preferredTags,proto3" json:"preferred_tags,omitempty"` // Preferred disk tags for placement
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) Reset() {
|
||||
*x = EcBalanceTaskConfig{}
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*EcBalanceTaskConfig) ProtoMessage() {}
|
||||
|
||||
func (x *EcBalanceTaskConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use EcBalanceTaskConfig.ProtoReflect.Descriptor instead.
|
||||
func (*EcBalanceTaskConfig) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{34}
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetImbalanceThreshold() float64 {
|
||||
if x != nil {
|
||||
return x.ImbalanceThreshold
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetMinServerCount() int32 {
|
||||
if x != nil {
|
||||
return x.MinServerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetCollectionFilter() string {
|
||||
if x != nil {
|
||||
return x.CollectionFilter
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetDiskType() string {
|
||||
if x != nil {
|
||||
return x.DiskType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *EcBalanceTaskConfig) GetPreferredTags() []string {
|
||||
if x != nil {
|
||||
return x.PreferredTags
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaintenanceTaskData represents complete task state for persistence
|
||||
type MaintenanceTaskData struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2884,7 +3155,7 @@ type MaintenanceTaskData struct {
|
||||
|
||||
func (x *MaintenanceTaskData) Reset() {
|
||||
*x = MaintenanceTaskData{}
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2896,7 +3167,7 @@ func (x *MaintenanceTaskData) String() string {
|
||||
func (*MaintenanceTaskData) ProtoMessage() {}
|
||||
|
||||
func (x *MaintenanceTaskData) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[32]
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2909,7 +3180,7 @@ func (x *MaintenanceTaskData) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MaintenanceTaskData.ProtoReflect.Descriptor instead.
|
||||
func (*MaintenanceTaskData) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{32}
|
||||
return file_worker_proto_rawDescGZIP(), []int{35}
|
||||
}
|
||||
|
||||
func (x *MaintenanceTaskData) GetId() string {
|
||||
@@ -3094,7 +3365,7 @@ type TaskAssignmentRecord struct {
|
||||
|
||||
func (x *TaskAssignmentRecord) Reset() {
|
||||
*x = TaskAssignmentRecord{}
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3106,7 +3377,7 @@ func (x *TaskAssignmentRecord) String() string {
|
||||
func (*TaskAssignmentRecord) ProtoMessage() {}
|
||||
|
||||
func (x *TaskAssignmentRecord) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[33]
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3119,7 +3390,7 @@ func (x *TaskAssignmentRecord) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TaskAssignmentRecord.ProtoReflect.Descriptor instead.
|
||||
func (*TaskAssignmentRecord) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{33}
|
||||
return file_worker_proto_rawDescGZIP(), []int{36}
|
||||
}
|
||||
|
||||
func (x *TaskAssignmentRecord) GetWorkerId() string {
|
||||
@@ -3171,7 +3442,7 @@ type TaskCreationMetrics struct {
|
||||
|
||||
func (x *TaskCreationMetrics) Reset() {
|
||||
*x = TaskCreationMetrics{}
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
mi := &file_worker_proto_msgTypes[37]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3183,7 +3454,7 @@ func (x *TaskCreationMetrics) String() string {
|
||||
func (*TaskCreationMetrics) ProtoMessage() {}
|
||||
|
||||
func (x *TaskCreationMetrics) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[34]
|
||||
mi := &file_worker_proto_msgTypes[37]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3196,7 +3467,7 @@ func (x *TaskCreationMetrics) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TaskCreationMetrics.ProtoReflect.Descriptor instead.
|
||||
func (*TaskCreationMetrics) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{34}
|
||||
return file_worker_proto_rawDescGZIP(), []int{37}
|
||||
}
|
||||
|
||||
func (x *TaskCreationMetrics) GetTriggerMetric() string {
|
||||
@@ -3253,7 +3524,7 @@ type VolumeHealthMetrics struct {
|
||||
|
||||
func (x *VolumeHealthMetrics) Reset() {
|
||||
*x = VolumeHealthMetrics{}
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
mi := &file_worker_proto_msgTypes[38]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3265,7 +3536,7 @@ func (x *VolumeHealthMetrics) String() string {
|
||||
func (*VolumeHealthMetrics) ProtoMessage() {}
|
||||
|
||||
func (x *VolumeHealthMetrics) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[35]
|
||||
mi := &file_worker_proto_msgTypes[38]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3278,7 +3549,7 @@ func (x *VolumeHealthMetrics) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use VolumeHealthMetrics.ProtoReflect.Descriptor instead.
|
||||
func (*VolumeHealthMetrics) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{35}
|
||||
return file_worker_proto_rawDescGZIP(), []int{38}
|
||||
}
|
||||
|
||||
func (x *VolumeHealthMetrics) GetTotalSize() uint64 {
|
||||
@@ -3363,7 +3634,7 @@ type TaskStateFile struct {
|
||||
|
||||
func (x *TaskStateFile) Reset() {
|
||||
*x = TaskStateFile{}
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
mi := &file_worker_proto_msgTypes[39]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -3375,7 +3646,7 @@ func (x *TaskStateFile) String() string {
|
||||
func (*TaskStateFile) ProtoMessage() {}
|
||||
|
||||
func (x *TaskStateFile) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[36]
|
||||
mi := &file_worker_proto_msgTypes[39]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -3388,7 +3659,7 @@ func (x *TaskStateFile) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use TaskStateFile.ProtoReflect.Descriptor instead.
|
||||
func (*TaskStateFile) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{36}
|
||||
return file_worker_proto_rawDescGZIP(), []int{39}
|
||||
}
|
||||
|
||||
func (x *TaskStateFile) GetTask() *MaintenanceTaskData {
|
||||
@@ -3477,7 +3748,7 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\bmetadata\x18\x06 \x03(\v2'.worker_pb.TaskAssignment.MetadataEntryR\bmetadata\x1a;\n" +
|
||||
"\rMetadataEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe1\x04\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xaf\x05\n" +
|
||||
"\n" +
|
||||
"TaskParams\x12\x17\n" +
|
||||
"\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1b\n" +
|
||||
@@ -3496,7 +3767,8 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\x15erasure_coding_params\x18\n" +
|
||||
" \x01(\v2\".worker_pb.ErasureCodingTaskParamsH\x00R\x13erasureCodingParams\x12E\n" +
|
||||
"\x0ebalance_params\x18\v \x01(\v2\x1c.worker_pb.BalanceTaskParamsH\x00R\rbalanceParams\x12Q\n" +
|
||||
"\x12replication_params\x18\f \x01(\v2 .worker_pb.ReplicationTaskParamsH\x00R\x11replicationParamsB\r\n" +
|
||||
"\x12replication_params\x18\f \x01(\v2 .worker_pb.ReplicationTaskParamsH\x00R\x11replicationParams\x12L\n" +
|
||||
"\x11ec_balance_params\x18\r \x01(\v2\x1e.worker_pb.EcBalanceTaskParamsH\x00R\x0fecBalanceParamsB\r\n" +
|
||||
"\vtask_params\"\xcb\x01\n" +
|
||||
"\x10VacuumTaskParams\x12+\n" +
|
||||
"\x11garbage_threshold\x18\x01 \x01(\x01R\x10garbageThreshold\x12!\n" +
|
||||
@@ -3658,7 +3930,7 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\x1edefault_check_interval_seconds\x18\x04 \x01(\x05R\x1bdefaultCheckIntervalSeconds\x1aV\n" +
|
||||
"\x11TaskPoliciesEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12+\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x15.worker_pb.TaskPolicyR\x05value:\x028\x01\"\x82\x04\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x15.worker_pb.TaskPolicyR\x05value:\x028\x01\"\xd0\x04\n" +
|
||||
"\n" +
|
||||
"TaskPolicy\x12\x18\n" +
|
||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12%\n" +
|
||||
@@ -3668,7 +3940,8 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\rvacuum_config\x18\x05 \x01(\v2\x1b.worker_pb.VacuumTaskConfigH\x00R\fvacuumConfig\x12X\n" +
|
||||
"\x15erasure_coding_config\x18\x06 \x01(\v2\".worker_pb.ErasureCodingTaskConfigH\x00R\x13erasureCodingConfig\x12E\n" +
|
||||
"\x0ebalance_config\x18\a \x01(\v2\x1c.worker_pb.BalanceTaskConfigH\x00R\rbalanceConfig\x12Q\n" +
|
||||
"\x12replication_config\x18\b \x01(\v2 .worker_pb.ReplicationTaskConfigH\x00R\x11replicationConfigB\r\n" +
|
||||
"\x12replication_config\x18\b \x01(\v2 .worker_pb.ReplicationTaskConfigH\x00R\x11replicationConfig\x12L\n" +
|
||||
"\x11ec_balance_config\x18\t \x01(\v2\x1e.worker_pb.EcBalanceTaskConfigH\x00R\x0fecBalanceConfigB\r\n" +
|
||||
"\vtask_config\"\xa2\x01\n" +
|
||||
"\x10VacuumTaskConfig\x12+\n" +
|
||||
"\x11garbage_threshold\x18\x01 \x01(\x01R\x10garbageThreshold\x12/\n" +
|
||||
@@ -3684,7 +3957,30 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\x13imbalance_threshold\x18\x01 \x01(\x01R\x12imbalanceThreshold\x12(\n" +
|
||||
"\x10min_server_count\x18\x02 \x01(\x05R\x0eminServerCount\"I\n" +
|
||||
"\x15ReplicationTaskConfig\x120\n" +
|
||||
"\x14target_replica_count\x18\x01 \x01(\x05R\x12targetReplicaCount\"\xae\a\n" +
|
||||
"\x14target_replica_count\x18\x01 \x01(\x05R\x12targetReplicaCount\"\xbe\x01\n" +
|
||||
"\x13EcBalanceTaskParams\x12\x1b\n" +
|
||||
"\tdisk_type\x18\x01 \x01(\tR\bdiskType\x12/\n" +
|
||||
"\x13max_parallelization\x18\x02 \x01(\x05R\x12maxParallelization\x12'\n" +
|
||||
"\x0ftimeout_seconds\x18\x03 \x01(\x05R\x0etimeoutSeconds\x120\n" +
|
||||
"\x05moves\x18\x04 \x03(\v2\x1a.worker_pb.EcShardMoveSpecR\x05moves\"\xf7\x01\n" +
|
||||
"\x0fEcShardMoveSpec\x12\x1b\n" +
|
||||
"\tvolume_id\x18\x01 \x01(\rR\bvolumeId\x12\x19\n" +
|
||||
"\bshard_id\x18\x02 \x01(\rR\ashardId\x12\x1e\n" +
|
||||
"\n" +
|
||||
"collection\x18\x03 \x01(\tR\n" +
|
||||
"collection\x12\x1f\n" +
|
||||
"\vsource_node\x18\x04 \x01(\tR\n" +
|
||||
"sourceNode\x12$\n" +
|
||||
"\x0esource_disk_id\x18\x05 \x01(\rR\fsourceDiskId\x12\x1f\n" +
|
||||
"\vtarget_node\x18\x06 \x01(\tR\n" +
|
||||
"targetNode\x12$\n" +
|
||||
"\x0etarget_disk_id\x18\a \x01(\rR\ftargetDiskId\"\xe1\x01\n" +
|
||||
"\x13EcBalanceTaskConfig\x12/\n" +
|
||||
"\x13imbalance_threshold\x18\x01 \x01(\x01R\x12imbalanceThreshold\x12(\n" +
|
||||
"\x10min_server_count\x18\x02 \x01(\x05R\x0eminServerCount\x12+\n" +
|
||||
"\x11collection_filter\x18\x03 \x01(\tR\x10collectionFilter\x12\x1b\n" +
|
||||
"\tdisk_type\x18\x04 \x01(\tR\bdiskType\x12%\n" +
|
||||
"\x0epreferred_tags\x18\x05 \x03(\tR\rpreferredTags\"\xae\a\n" +
|
||||
"\x13MaintenanceTaskData\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04type\x18\x02 \x01(\tR\x04type\x12\x1a\n" +
|
||||
@@ -3773,7 +4069,7 @@ func file_worker_proto_rawDescGZIP() []byte {
|
||||
return file_worker_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_worker_proto_msgTypes = make([]protoimpl.MessageInfo, 46)
|
||||
var file_worker_proto_msgTypes = make([]protoimpl.MessageInfo, 49)
|
||||
var file_worker_proto_goTypes = []any{
|
||||
(*WorkerMessage)(nil), // 0: worker_pb.WorkerMessage
|
||||
(*AdminMessage)(nil), // 1: worker_pb.AdminMessage
|
||||
@@ -3807,20 +4103,23 @@ var file_worker_proto_goTypes = []any{
|
||||
(*ErasureCodingTaskConfig)(nil), // 29: worker_pb.ErasureCodingTaskConfig
|
||||
(*BalanceTaskConfig)(nil), // 30: worker_pb.BalanceTaskConfig
|
||||
(*ReplicationTaskConfig)(nil), // 31: worker_pb.ReplicationTaskConfig
|
||||
(*MaintenanceTaskData)(nil), // 32: worker_pb.MaintenanceTaskData
|
||||
(*TaskAssignmentRecord)(nil), // 33: worker_pb.TaskAssignmentRecord
|
||||
(*TaskCreationMetrics)(nil), // 34: worker_pb.TaskCreationMetrics
|
||||
(*VolumeHealthMetrics)(nil), // 35: worker_pb.VolumeHealthMetrics
|
||||
(*TaskStateFile)(nil), // 36: worker_pb.TaskStateFile
|
||||
nil, // 37: worker_pb.WorkerRegistration.MetadataEntry
|
||||
nil, // 38: worker_pb.TaskAssignment.MetadataEntry
|
||||
nil, // 39: worker_pb.TaskUpdate.MetadataEntry
|
||||
nil, // 40: worker_pb.TaskComplete.ResultMetadataEntry
|
||||
nil, // 41: worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
nil, // 42: worker_pb.TaskLogEntry.FieldsEntry
|
||||
nil, // 43: worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
nil, // 44: worker_pb.MaintenanceTaskData.TagsEntry
|
||||
nil, // 45: worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
(*EcBalanceTaskParams)(nil), // 32: worker_pb.EcBalanceTaskParams
|
||||
(*EcShardMoveSpec)(nil), // 33: worker_pb.EcShardMoveSpec
|
||||
(*EcBalanceTaskConfig)(nil), // 34: worker_pb.EcBalanceTaskConfig
|
||||
(*MaintenanceTaskData)(nil), // 35: worker_pb.MaintenanceTaskData
|
||||
(*TaskAssignmentRecord)(nil), // 36: worker_pb.TaskAssignmentRecord
|
||||
(*TaskCreationMetrics)(nil), // 37: worker_pb.TaskCreationMetrics
|
||||
(*VolumeHealthMetrics)(nil), // 38: worker_pb.VolumeHealthMetrics
|
||||
(*TaskStateFile)(nil), // 39: worker_pb.TaskStateFile
|
||||
nil, // 40: worker_pb.WorkerRegistration.MetadataEntry
|
||||
nil, // 41: worker_pb.TaskAssignment.MetadataEntry
|
||||
nil, // 42: worker_pb.TaskUpdate.MetadataEntry
|
||||
nil, // 43: worker_pb.TaskComplete.ResultMetadataEntry
|
||||
nil, // 44: worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
nil, // 45: worker_pb.TaskLogEntry.FieldsEntry
|
||||
nil, // 46: worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
nil, // 47: worker_pb.MaintenanceTaskData.TagsEntry
|
||||
nil, // 48: worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
}
|
||||
var file_worker_proto_depIdxs = []int32{
|
||||
2, // 0: worker_pb.WorkerMessage.registration:type_name -> worker_pb.WorkerRegistration
|
||||
@@ -3836,43 +4135,46 @@ var file_worker_proto_depIdxs = []int32{
|
||||
18, // 10: worker_pb.AdminMessage.task_cancellation:type_name -> worker_pb.TaskCancellation
|
||||
20, // 11: worker_pb.AdminMessage.admin_shutdown:type_name -> worker_pb.AdminShutdown
|
||||
21, // 12: worker_pb.AdminMessage.task_log_request:type_name -> worker_pb.TaskLogRequest
|
||||
37, // 13: worker_pb.WorkerRegistration.metadata:type_name -> worker_pb.WorkerRegistration.MetadataEntry
|
||||
40, // 13: worker_pb.WorkerRegistration.metadata:type_name -> worker_pb.WorkerRegistration.MetadataEntry
|
||||
8, // 14: worker_pb.TaskAssignment.params:type_name -> worker_pb.TaskParams
|
||||
38, // 15: worker_pb.TaskAssignment.metadata:type_name -> worker_pb.TaskAssignment.MetadataEntry
|
||||
41, // 15: worker_pb.TaskAssignment.metadata:type_name -> worker_pb.TaskAssignment.MetadataEntry
|
||||
11, // 16: worker_pb.TaskParams.sources:type_name -> worker_pb.TaskSource
|
||||
12, // 17: worker_pb.TaskParams.targets:type_name -> worker_pb.TaskTarget
|
||||
9, // 18: worker_pb.TaskParams.vacuum_params:type_name -> worker_pb.VacuumTaskParams
|
||||
10, // 19: worker_pb.TaskParams.erasure_coding_params:type_name -> worker_pb.ErasureCodingTaskParams
|
||||
14, // 20: worker_pb.TaskParams.balance_params:type_name -> worker_pb.BalanceTaskParams
|
||||
15, // 21: worker_pb.TaskParams.replication_params:type_name -> worker_pb.ReplicationTaskParams
|
||||
13, // 22: worker_pb.BalanceTaskParams.moves:type_name -> worker_pb.BalanceMoveSpec
|
||||
39, // 23: worker_pb.TaskUpdate.metadata:type_name -> worker_pb.TaskUpdate.MetadataEntry
|
||||
40, // 24: worker_pb.TaskComplete.result_metadata:type_name -> worker_pb.TaskComplete.ResultMetadataEntry
|
||||
23, // 25: worker_pb.TaskLogResponse.metadata:type_name -> worker_pb.TaskLogMetadata
|
||||
24, // 26: worker_pb.TaskLogResponse.log_entries:type_name -> worker_pb.TaskLogEntry
|
||||
41, // 27: worker_pb.TaskLogMetadata.custom_data:type_name -> worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
42, // 28: worker_pb.TaskLogEntry.fields:type_name -> worker_pb.TaskLogEntry.FieldsEntry
|
||||
26, // 29: worker_pb.MaintenanceConfig.policy:type_name -> worker_pb.MaintenancePolicy
|
||||
43, // 30: worker_pb.MaintenancePolicy.task_policies:type_name -> worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
28, // 31: worker_pb.TaskPolicy.vacuum_config:type_name -> worker_pb.VacuumTaskConfig
|
||||
29, // 32: worker_pb.TaskPolicy.erasure_coding_config:type_name -> worker_pb.ErasureCodingTaskConfig
|
||||
30, // 33: worker_pb.TaskPolicy.balance_config:type_name -> worker_pb.BalanceTaskConfig
|
||||
31, // 34: worker_pb.TaskPolicy.replication_config:type_name -> worker_pb.ReplicationTaskConfig
|
||||
8, // 35: worker_pb.MaintenanceTaskData.typed_params:type_name -> worker_pb.TaskParams
|
||||
33, // 36: worker_pb.MaintenanceTaskData.assignment_history:type_name -> worker_pb.TaskAssignmentRecord
|
||||
44, // 37: worker_pb.MaintenanceTaskData.tags:type_name -> worker_pb.MaintenanceTaskData.TagsEntry
|
||||
34, // 38: worker_pb.MaintenanceTaskData.creation_metrics:type_name -> worker_pb.TaskCreationMetrics
|
||||
35, // 39: worker_pb.TaskCreationMetrics.volume_metrics:type_name -> worker_pb.VolumeHealthMetrics
|
||||
45, // 40: worker_pb.TaskCreationMetrics.additional_data:type_name -> worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
32, // 41: worker_pb.TaskStateFile.task:type_name -> worker_pb.MaintenanceTaskData
|
||||
27, // 42: worker_pb.MaintenancePolicy.TaskPoliciesEntry.value:type_name -> worker_pb.TaskPolicy
|
||||
0, // 43: worker_pb.WorkerService.WorkerStream:input_type -> worker_pb.WorkerMessage
|
||||
1, // 44: worker_pb.WorkerService.WorkerStream:output_type -> worker_pb.AdminMessage
|
||||
44, // [44:45] is the sub-list for method output_type
|
||||
43, // [43:44] is the sub-list for method input_type
|
||||
43, // [43:43] is the sub-list for extension type_name
|
||||
43, // [43:43] is the sub-list for extension extendee
|
||||
0, // [0:43] is the sub-list for field type_name
|
||||
32, // 22: worker_pb.TaskParams.ec_balance_params:type_name -> worker_pb.EcBalanceTaskParams
|
||||
13, // 23: worker_pb.BalanceTaskParams.moves:type_name -> worker_pb.BalanceMoveSpec
|
||||
42, // 24: worker_pb.TaskUpdate.metadata:type_name -> worker_pb.TaskUpdate.MetadataEntry
|
||||
43, // 25: worker_pb.TaskComplete.result_metadata:type_name -> worker_pb.TaskComplete.ResultMetadataEntry
|
||||
23, // 26: worker_pb.TaskLogResponse.metadata:type_name -> worker_pb.TaskLogMetadata
|
||||
24, // 27: worker_pb.TaskLogResponse.log_entries:type_name -> worker_pb.TaskLogEntry
|
||||
44, // 28: worker_pb.TaskLogMetadata.custom_data:type_name -> worker_pb.TaskLogMetadata.CustomDataEntry
|
||||
45, // 29: worker_pb.TaskLogEntry.fields:type_name -> worker_pb.TaskLogEntry.FieldsEntry
|
||||
26, // 30: worker_pb.MaintenanceConfig.policy:type_name -> worker_pb.MaintenancePolicy
|
||||
46, // 31: worker_pb.MaintenancePolicy.task_policies:type_name -> worker_pb.MaintenancePolicy.TaskPoliciesEntry
|
||||
28, // 32: worker_pb.TaskPolicy.vacuum_config:type_name -> worker_pb.VacuumTaskConfig
|
||||
29, // 33: worker_pb.TaskPolicy.erasure_coding_config:type_name -> worker_pb.ErasureCodingTaskConfig
|
||||
30, // 34: worker_pb.TaskPolicy.balance_config:type_name -> worker_pb.BalanceTaskConfig
|
||||
31, // 35: worker_pb.TaskPolicy.replication_config:type_name -> worker_pb.ReplicationTaskConfig
|
||||
34, // 36: worker_pb.TaskPolicy.ec_balance_config:type_name -> worker_pb.EcBalanceTaskConfig
|
||||
33, // 37: worker_pb.EcBalanceTaskParams.moves:type_name -> worker_pb.EcShardMoveSpec
|
||||
8, // 38: worker_pb.MaintenanceTaskData.typed_params:type_name -> worker_pb.TaskParams
|
||||
36, // 39: worker_pb.MaintenanceTaskData.assignment_history:type_name -> worker_pb.TaskAssignmentRecord
|
||||
47, // 40: worker_pb.MaintenanceTaskData.tags:type_name -> worker_pb.MaintenanceTaskData.TagsEntry
|
||||
37, // 41: worker_pb.MaintenanceTaskData.creation_metrics:type_name -> worker_pb.TaskCreationMetrics
|
||||
38, // 42: worker_pb.TaskCreationMetrics.volume_metrics:type_name -> worker_pb.VolumeHealthMetrics
|
||||
48, // 43: worker_pb.TaskCreationMetrics.additional_data:type_name -> worker_pb.TaskCreationMetrics.AdditionalDataEntry
|
||||
35, // 44: worker_pb.TaskStateFile.task:type_name -> worker_pb.MaintenanceTaskData
|
||||
27, // 45: worker_pb.MaintenancePolicy.TaskPoliciesEntry.value:type_name -> worker_pb.TaskPolicy
|
||||
0, // 46: worker_pb.WorkerService.WorkerStream:input_type -> worker_pb.WorkerMessage
|
||||
1, // 47: worker_pb.WorkerService.WorkerStream:output_type -> worker_pb.AdminMessage
|
||||
47, // [47:48] is the sub-list for method output_type
|
||||
46, // [46:47] is the sub-list for method input_type
|
||||
46, // [46:46] is the sub-list for extension type_name
|
||||
46, // [46:46] is the sub-list for extension extendee
|
||||
0, // [0:46] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_worker_proto_init() }
|
||||
@@ -3902,12 +4204,14 @@ func file_worker_proto_init() {
|
||||
(*TaskParams_ErasureCodingParams)(nil),
|
||||
(*TaskParams_BalanceParams)(nil),
|
||||
(*TaskParams_ReplicationParams)(nil),
|
||||
(*TaskParams_EcBalanceParams)(nil),
|
||||
}
|
||||
file_worker_proto_msgTypes[27].OneofWrappers = []any{
|
||||
(*TaskPolicy_VacuumConfig)(nil),
|
||||
(*TaskPolicy_ErasureCodingConfig)(nil),
|
||||
(*TaskPolicy_BalanceConfig)(nil),
|
||||
(*TaskPolicy_ReplicationConfig)(nil),
|
||||
(*TaskPolicy_EcBalanceConfig)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
@@ -3915,7 +4219,7 @@ func file_worker_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_worker_proto_rawDesc), len(file_worker_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 46,
|
||||
NumMessages: 49,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
ecbalancetask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/ec_balance"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
ecBalanceMinImbalanceThreshold = 0.05
|
||||
ecBalanceMaxImbalanceThreshold = 0.5
|
||||
ecBalanceMinServerCount = 2
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "ec_balance",
|
||||
Category: CategoryDefault,
|
||||
Aliases: []string{"ec-balance", "ec.balance", "ec_shard_balance"},
|
||||
Build: func(opts HandlerBuildOptions) (JobHandler, error) {
|
||||
return NewECBalanceHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type ecBalanceWorkerConfig struct {
|
||||
TaskConfig *ecbalancetask.Config
|
||||
MinIntervalSeconds int
|
||||
}
|
||||
|
||||
// ECBalanceHandler is the plugin job handler for EC shard balancing.
|
||||
type ECBalanceHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
func NewECBalanceHandler(grpcDialOption grpc.DialOption) *ECBalanceHandler {
|
||||
return &ECBalanceHandler{grpcDialOption: grpcDialOption}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: "ec_balance",
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 3,
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Balance EC shard distribution across racks and nodes",
|
||||
Weight: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: "ec_balance",
|
||||
DisplayName: "EC Shard Balance",
|
||||
Description: "Detect and execute EC shard rebalancing across the cluster",
|
||||
Icon: "fas fa-balance-scale-left",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "ec-balance-admin",
|
||||
Title: "EC Shard Balance Admin Config",
|
||||
Description: "Admin-side controls for EC shard balance detection scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Optional filters applied before EC shard balance detection.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only balance EC shards in matching collections (wildcard supported).",
|
||||
Placeholder: "all collections",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only balance within matching data centers (wildcard supported).",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "disk_type",
|
||||
Label: "Disk Type",
|
||||
Description: "Only balance EC shards on this disk type (hdd, ssd, or empty for all).",
|
||||
Placeholder: "all disk types",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"collection_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"data_center_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"disk_type": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "ec-balance-worker",
|
||||
Title: "EC Shard Balance Worker Config",
|
||||
Description: "Worker-side detection thresholds and execution controls.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "thresholds",
|
||||
Title: "Detection Thresholds",
|
||||
Description: "Controls for when EC shard balance jobs should be proposed.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "imbalance_threshold",
|
||||
Label: "Imbalance Threshold",
|
||||
Description: "Minimum shard count imbalance ratio to trigger balancing (0.0-1.0).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_DOUBLE,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMinImbalanceThreshold}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: ecBalanceMaxImbalanceThreshold}},
|
||||
},
|
||||
{
|
||||
Name: "min_server_count",
|
||||
Label: "Minimum Server Count",
|
||||
Description: "Minimum servers required for EC shard balancing.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: ecBalanceMinServerCount}},
|
||||
},
|
||||
{
|
||||
Name: "min_interval_seconds",
|
||||
Label: "Minimum Detection Interval (s)",
|
||||
Description: "Skip detection if the last successful run is more recent than this interval.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
Required: true,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
},
|
||||
{
|
||||
Name: "preferred_tags",
|
||||
Label: "Preferred Tags",
|
||||
Description: "Comma-separated disk tags to prioritize for shard placement, ordered by preference.",
|
||||
Placeholder: "fast,ssd",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"min_interval_seconds": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 60 * 60}},
|
||||
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 60 * 30,
|
||||
DetectionTimeoutSeconds: 300,
|
||||
MaxJobsPerDetection: 500,
|
||||
GlobalExecutionConcurrency: 16,
|
||||
PerWorkerExecutionConcurrency: 4,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 30,
|
||||
JobTypeMaxRuntimeSeconds: 1800,
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.2}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"min_interval_seconds": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 60 * 60}},
|
||||
"preferred_tags": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Detect(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.RunDetectionRequest,
|
||||
sender DetectionSender,
|
||||
) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != "ec_balance" {
|
||||
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := deriveECBalanceWorkerConfig(request.GetWorkerConfigValues())
|
||||
if ShouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
minInterval := time.Duration(workerConfig.MinIntervalSeconds) * time.Second
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"skipped_by_interval",
|
||||
fmt.Sprintf("EC BALANCE: Detection skipped due to min interval (%s)", minInterval),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
"min_interval_seconds": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(workerConfig.MinIntervalSeconds)},
|
||||
},
|
||||
},
|
||||
))
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "ec_balance",
|
||||
Proposals: []*plugin_pb.JobProposal{},
|
||||
HasMore: false,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "ec_balance",
|
||||
Success: true,
|
||||
TotalProposals: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Apply admin-side scope filters
|
||||
collectionFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
|
||||
if collectionFilter != "" {
|
||||
workerConfig.TaskConfig.CollectionFilter = collectionFilter
|
||||
}
|
||||
dcFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
if dcFilter != "" {
|
||||
workerConfig.TaskConfig.DataCenterFilter = dcFilter
|
||||
}
|
||||
diskType := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "disk_type", ""))
|
||||
if diskType != "" {
|
||||
workerConfig.TaskConfig.DiskType = diskType
|
||||
}
|
||||
|
||||
masters := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
maxResults := int(request.MaxResults)
|
||||
if maxResults < 0 {
|
||||
maxResults = 0
|
||||
}
|
||||
|
||||
results, hasMore, err := ecbalancetask.Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if traceErr := emitECBalanceDecisionTrace(sender, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
|
||||
glog.Warningf("Plugin worker failed to emit ec_balance detection trace: %v", traceErr)
|
||||
}
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
|
||||
for _, result := range results {
|
||||
proposal, proposalErr := buildECBalanceProposal(result)
|
||||
if proposalErr != nil {
|
||||
glog.Warningf("Plugin worker skip invalid ec_balance proposal: %v", proposalErr)
|
||||
continue
|
||||
}
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: "ec_balance",
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: "ec_balance",
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) Execute(
|
||||
ctx context.Context,
|
||||
request *plugin_pb.ExecuteJobRequest,
|
||||
sender ExecutionSender,
|
||||
) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != "ec_balance" {
|
||||
return fmt.Errorf("job type %q is not handled by ec_balance worker", request.Job.JobType)
|
||||
}
|
||||
|
||||
params, err := decodeECBalanceTaskParams(request.Job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return fmt.Errorf("ec balance source node is required")
|
||||
}
|
||||
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
|
||||
return fmt.Errorf("ec balance target node is required")
|
||||
}
|
||||
|
||||
task := ecbalancetask.NewECBalanceTask(
|
||||
request.Job.JobId,
|
||||
params.VolumeId,
|
||||
params.Collection,
|
||||
h.grpcDialOption,
|
||||
)
|
||||
|
||||
execCtx, execCancel := context.WithCancel(ctx)
|
||||
defer execCancel()
|
||||
|
||||
task.SetProgressCallback(func(progress float64, stage string) {
|
||||
message := fmt.Sprintf("ec balance progress %.0f%%", progress)
|
||||
if strings.TrimSpace(stage) != "" {
|
||||
message = stage
|
||||
}
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity(stage, message),
|
||||
},
|
||||
}); err != nil {
|
||||
glog.Warningf("EC balance job %s (%s): failed to send progress (%.0f%%, stage=%q): %v, cancelling execution",
|
||||
request.Job.JobId, request.Job.JobType, progress, stage, err)
|
||||
execCancel()
|
||||
}
|
||||
})
|
||||
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: "ec balance job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity("assigned", "ec balance job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := task.Execute(execCtx, params); err != nil {
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_FAILED,
|
||||
ProgressPercent: 100,
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
sourceNode := params.Sources[0].Node
|
||||
targetNode := params.Targets[0].Node
|
||||
resultSummary := fmt.Sprintf("EC shard balance completed: volume %d shards moved from %s to %s",
|
||||
params.VolumeId, sourceNode, targetNode)
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: request.Job.JobType,
|
||||
Success: true,
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(params.VolumeId)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
|
||||
},
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ECBalanceHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
metrics, activeTopology, _, err := collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveECBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *ecBalanceWorkerConfig {
|
||||
taskConfig := ecbalancetask.NewDefaultConfig()
|
||||
|
||||
imbalanceThreshold := readDoubleConfig(values, "imbalance_threshold", taskConfig.ImbalanceThreshold)
|
||||
if imbalanceThreshold < ecBalanceMinImbalanceThreshold {
|
||||
imbalanceThreshold = ecBalanceMinImbalanceThreshold
|
||||
}
|
||||
if imbalanceThreshold > ecBalanceMaxImbalanceThreshold {
|
||||
imbalanceThreshold = ecBalanceMaxImbalanceThreshold
|
||||
}
|
||||
taskConfig.ImbalanceThreshold = imbalanceThreshold
|
||||
|
||||
minServerCountRaw := readInt64Config(values, "min_server_count", int64(taskConfig.MinServerCount))
|
||||
if minServerCountRaw < int64(ecBalanceMinServerCount) {
|
||||
minServerCountRaw = int64(ecBalanceMinServerCount)
|
||||
}
|
||||
if minServerCountRaw > math.MaxInt32 {
|
||||
minServerCountRaw = math.MaxInt32
|
||||
}
|
||||
taskConfig.MinServerCount = int(minServerCountRaw)
|
||||
|
||||
minIntervalRaw := readInt64Config(values, "min_interval_seconds", 60*60)
|
||||
if minIntervalRaw < 0 {
|
||||
minIntervalRaw = 0
|
||||
}
|
||||
if minIntervalRaw > math.MaxInt32 {
|
||||
minIntervalRaw = math.MaxInt32
|
||||
}
|
||||
minIntervalSeconds := int(minIntervalRaw)
|
||||
|
||||
taskConfig.PreferredTags = util.NormalizeTagList(readStringListConfig(values, "preferred_tags"))
|
||||
|
||||
return &ecBalanceWorkerConfig{
|
||||
TaskConfig: taskConfig,
|
||||
MinIntervalSeconds: minIntervalSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func buildECBalanceProposal(result *workertypes.TaskDetectionResult) (*plugin_pb.JobProposal, error) {
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("task detection result is nil")
|
||||
}
|
||||
if result.TypedParams == nil {
|
||||
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
|
||||
}
|
||||
|
||||
paramsPayload, err := proto.Marshal(result.TypedParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal task params: %w", err)
|
||||
}
|
||||
|
||||
proposalID := strings.TrimSpace(result.TaskID)
|
||||
if proposalID == "" {
|
||||
proposalID = fmt.Sprintf("ec-balance-%d-%d", result.VolumeID, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Dedupe key includes volume ID, shard ID, source node, and collection
|
||||
// to distinguish moves of the same shard from different source nodes (e.g. dedup)
|
||||
dedupeKey := fmt.Sprintf("ec_balance:%d", result.VolumeID)
|
||||
if len(result.TypedParams.Sources) > 0 {
|
||||
src := result.TypedParams.Sources[0]
|
||||
if len(src.ShardIds) > 0 {
|
||||
dedupeKey = fmt.Sprintf("ec_balance:%d:%d:%s", result.VolumeID, src.ShardIds[0], src.Node)
|
||||
}
|
||||
}
|
||||
if result.Collection != "" {
|
||||
dedupeKey += ":" + result.Collection
|
||||
}
|
||||
|
||||
sourceNode := ""
|
||||
targetNode := ""
|
||||
if len(result.TypedParams.Sources) > 0 {
|
||||
sourceNode = strings.TrimSpace(result.TypedParams.Sources[0].Node)
|
||||
}
|
||||
if len(result.TypedParams.Targets) > 0 {
|
||||
targetNode = strings.TrimSpace(result.TypedParams.Targets[0].Node)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Balance EC shard of volume %d", result.VolumeID)
|
||||
if sourceNode != "" && targetNode != "" {
|
||||
summary = fmt.Sprintf("Move EC shard of volume %d: %s → %s", result.VolumeID, sourceNode, targetNode)
|
||||
}
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: proposalID,
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: "ec_balance",
|
||||
Priority: mapTaskPriority(result.Priority),
|
||||
Summary: summary,
|
||||
Detail: strings.TrimSpace(result.Reason),
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {
|
||||
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: paramsPayload},
|
||||
},
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(result.VolumeID)},
|
||||
},
|
||||
"source_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceNode},
|
||||
},
|
||||
"target_server": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: targetNode},
|
||||
},
|
||||
"collection": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: result.Collection},
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "ec_balance",
|
||||
"volume_id": fmt.Sprintf("%d", result.VolumeID),
|
||||
"collection": result.Collection,
|
||||
"source_node": sourceNode,
|
||||
"target_node": targetNode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeECBalanceTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParams, error) {
|
||||
if job == nil {
|
||||
return nil, fmt.Errorf("job spec is nil")
|
||||
}
|
||||
|
||||
// Try protobuf-encoded params first (preferred path)
|
||||
if payload := readBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
|
||||
params := &worker_pb.TaskParams{}
|
||||
if err := proto.Unmarshal(payload, params); err != nil {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: unmarshal task_params_pb: %w", err)
|
||||
}
|
||||
if params.TaskId == "" {
|
||||
params.TaskId = job.JobId
|
||||
}
|
||||
// Validate execution-critical fields in the deserialized TaskParams
|
||||
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].Node")
|
||||
}
|
||||
if len(params.Sources[0].ShardIds) == 0 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Sources[0].ShardIds")
|
||||
}
|
||||
if len(params.Targets) == 0 || strings.TrimSpace(params.Targets[0].Node) == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].Node")
|
||||
}
|
||||
if len(params.Targets[0].ShardIds) == 0 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: TaskParams missing Targets[0].ShardIds")
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// Legacy fallback: construct TaskParams from individual scalar parameters.
|
||||
// All execution-critical fields are required.
|
||||
volumeID := readInt64Config(job.Parameters, "volume_id", 0)
|
||||
sourceNode := strings.TrimSpace(readStringConfig(job.Parameters, "source_server", ""))
|
||||
targetNode := strings.TrimSpace(readStringConfig(job.Parameters, "target_server", ""))
|
||||
collection := readStringConfig(job.Parameters, "collection", "")
|
||||
|
||||
if volumeID <= 0 || volumeID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid or missing volume_id: %d", volumeID)
|
||||
}
|
||||
if sourceNode == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing source_server")
|
||||
}
|
||||
if targetNode == "" {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing target_server")
|
||||
}
|
||||
|
||||
shardIDVal, hasShardID := job.Parameters["shard_id"]
|
||||
if !hasShardID || shardIDVal == nil {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: missing shard_id (required for EcBalanceTaskParams)")
|
||||
}
|
||||
shardID := readInt64Config(job.Parameters, "shard_id", -1)
|
||||
if shardID < 0 || shardID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid shard_id: %d", shardID)
|
||||
}
|
||||
|
||||
sourceDiskID := readInt64Config(job.Parameters, "source_disk_id", 0)
|
||||
if sourceDiskID < 0 || sourceDiskID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid source_disk_id: %d", sourceDiskID)
|
||||
}
|
||||
targetDiskID := readInt64Config(job.Parameters, "target_disk_id", 0)
|
||||
if targetDiskID < 0 || targetDiskID > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("decodeECBalanceTaskParams: invalid target_disk_id: %d", targetDiskID)
|
||||
}
|
||||
|
||||
return &worker_pb.TaskParams{
|
||||
TaskId: job.JobId,
|
||||
VolumeId: uint32(volumeID),
|
||||
Collection: collection,
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: sourceNode,
|
||||
DiskId: uint32(sourceDiskID),
|
||||
ShardIds: []uint32{uint32(shardID)},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: targetNode,
|
||||
DiskId: uint32(targetDiskID),
|
||||
ShardIds: []uint32{uint32(shardID)},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
TimeoutSeconds: 600,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func emitECBalanceDecisionTrace(
|
||||
sender DetectionSender,
|
||||
taskConfig *ecbalancetask.Config,
|
||||
results []*workertypes.TaskDetectionResult,
|
||||
maxResults int,
|
||||
hasMore bool,
|
||||
) error {
|
||||
if sender == nil || taskConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count moves by phase
|
||||
phaseCounts := make(map[string]int)
|
||||
for _, result := range results {
|
||||
if result.Reason != "" {
|
||||
// Extract phase from reason string
|
||||
for _, phase := range []string{"dedup", "cross_rack", "within_rack", "global"} {
|
||||
if strings.Contains(result.Reason, phase) {
|
||||
phaseCounts[phase]++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summarySuffix := ""
|
||||
if hasMore {
|
||||
summarySuffix = fmt.Sprintf(" (max_results=%d reached)", maxResults)
|
||||
}
|
||||
|
||||
summaryMessage := fmt.Sprintf(
|
||||
"EC balance detection: %d moves proposed%s (dedup=%d, cross_rack=%d, within_rack=%d, global=%d)",
|
||||
len(results),
|
||||
summarySuffix,
|
||||
phaseCounts["dedup"],
|
||||
phaseCounts["cross_rack"],
|
||||
phaseCounts["within_rack"],
|
||||
phaseCounts["global"],
|
||||
)
|
||||
|
||||
return sender.SendActivity(BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
|
||||
},
|
||||
"has_more": {
|
||||
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
|
||||
},
|
||||
"dedup_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["dedup"])},
|
||||
},
|
||||
"cross_rack_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["cross_rack"])},
|
||||
},
|
||||
"within_rack_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["within_rack"])},
|
||||
},
|
||||
"global_moves": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(phaseCounts["global"])},
|
||||
},
|
||||
"imbalance_threshold": {
|
||||
Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: taskConfig.ImbalanceThreshold},
|
||||
},
|
||||
"min_server_count": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(taskConfig.MinServerCount)},
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
ecbalancetask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/ec_balance"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDeriveECBalanceWorkerConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]*plugin_pb.ConfigValue
|
||||
check func(t *testing.T, config *ecBalanceWorkerConfig)
|
||||
}{
|
||||
{
|
||||
name: "nil values uses defaults",
|
||||
values: nil,
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.2 {
|
||||
t.Errorf("expected default threshold 0.2, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if config.TaskConfig.MinServerCount != 3 {
|
||||
t.Errorf("expected default min_server_count 3, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom threshold",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.3}},
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 5}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.3 {
|
||||
t.Errorf("expected threshold 0.3, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
if config.TaskConfig.MinServerCount != 5 {
|
||||
t.Errorf("expected min_server_count 5, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold clamped to min",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.01}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.05 {
|
||||
t.Errorf("expected clamped threshold 0.05, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold clamped to max",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"imbalance_threshold": {Kind: &plugin_pb.ConfigValue_DoubleValue{DoubleValue: 0.9}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.ImbalanceThreshold != 0.5 {
|
||||
t.Errorf("expected clamped threshold 0.5, got %f", config.TaskConfig.ImbalanceThreshold)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "min_server_count clamped to 2",
|
||||
values: map[string]*plugin_pb.ConfigValue{
|
||||
"min_server_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
check: func(t *testing.T, config *ecBalanceWorkerConfig) {
|
||||
if config.TaskConfig.MinServerCount != 2 {
|
||||
t.Errorf("expected min_server_count 2, got %d", config.TaskConfig.MinServerCount)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := deriveECBalanceWorkerConfig(tt.values)
|
||||
tt.check(t, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildECBalanceProposal(t *testing.T) {
|
||||
result := &workertypes.TaskDetectionResult{
|
||||
TaskID: "test-task-123",
|
||||
TaskType: workertypes.TaskTypeECBalance,
|
||||
VolumeID: 42,
|
||||
Collection: "test-col",
|
||||
Priority: workertypes.TaskPriorityMedium,
|
||||
Reason: "cross_rack balance",
|
||||
TypedParams: &worker_pb.TaskParams{
|
||||
VolumeId: 42,
|
||||
Collection: "test-col",
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: "source:8080",
|
||||
ShardIds: []uint32{5},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: "target:8080",
|
||||
ShardIds: []uint32{5},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
proposal, err := buildECBalanceProposal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if proposal.JobType != "ec_balance" {
|
||||
t.Errorf("expected job type ec_balance, got %s", proposal.JobType)
|
||||
}
|
||||
if proposal.ProposalId != "test-task-123" {
|
||||
t.Errorf("expected proposal ID test-task-123, got %s", proposal.ProposalId)
|
||||
}
|
||||
if proposal.DedupeKey != "ec_balance:42:5:source:8080:test-col" {
|
||||
t.Errorf("expected dedupe key ec_balance:42:5:source:8080:test-col, got %s", proposal.DedupeKey)
|
||||
}
|
||||
if proposal.Labels["source_node"] != "source:8080" {
|
||||
t.Errorf("expected source_node label source:8080, got %s", proposal.Labels["source_node"])
|
||||
}
|
||||
if proposal.Labels["target_node"] != "target:8080" {
|
||||
t.Errorf("expected target_node label target:8080, got %s", proposal.Labels["target_node"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildECBalanceProposalNilResult(t *testing.T) {
|
||||
_, err := buildECBalanceProposal(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsFromProtobuf(t *testing.T) {
|
||||
originalParams := &worker_pb.TaskParams{
|
||||
TaskId: "test-id",
|
||||
VolumeId: 100,
|
||||
Collection: "test-col",
|
||||
Sources: []*worker_pb.TaskSource{{
|
||||
Node: "source:8080",
|
||||
ShardIds: []uint32{3},
|
||||
}},
|
||||
Targets: []*worker_pb.TaskTarget{{
|
||||
Node: "target:8080",
|
||||
ShardIds: []uint32{3},
|
||||
}},
|
||||
TaskParams: &worker_pb.TaskParams_EcBalanceParams{
|
||||
EcBalanceParams: &worker_pb.EcBalanceTaskParams{
|
||||
DiskType: "hdd",
|
||||
TimeoutSeconds: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := proto.Marshal(originalParams)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-1",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
|
||||
decoded, err := decodeECBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode: %v", err)
|
||||
}
|
||||
|
||||
if decoded.VolumeId != 100 {
|
||||
t.Errorf("expected volume_id 100, got %d", decoded.VolumeId)
|
||||
}
|
||||
if decoded.Collection != "test-col" {
|
||||
t.Errorf("expected collection test-col, got %s", decoded.Collection)
|
||||
}
|
||||
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "source:8080" {
|
||||
t.Error("source mismatch")
|
||||
}
|
||||
if len(decoded.Targets) != 1 || decoded.Targets[0].Node != "target:8080" {
|
||||
t.Error("target mismatch")
|
||||
}
|
||||
ecParams := decoded.GetEcBalanceParams()
|
||||
if ecParams == nil {
|
||||
t.Fatal("expected ec_balance_params")
|
||||
}
|
||||
if ecParams.DiskType != "hdd" {
|
||||
t.Errorf("expected disk_type hdd, got %s", ecParams.DiskType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsFallback(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-2",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 200}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "fallback-col"}},
|
||||
"shard_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 7}},
|
||||
"source_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
"target_disk_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
}
|
||||
|
||||
decoded, err := decodeECBalanceTaskParams(job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode fallback: %v", err)
|
||||
}
|
||||
|
||||
if decoded.VolumeId != 200 {
|
||||
t.Errorf("expected volume_id 200, got %d", decoded.VolumeId)
|
||||
}
|
||||
if len(decoded.Sources) != 1 || decoded.Sources[0].Node != "src:8080" {
|
||||
t.Error("source mismatch")
|
||||
}
|
||||
if decoded.Sources[0].ShardIds[0] != 7 {
|
||||
t.Errorf("expected shard_id 7, got %d", decoded.Sources[0].ShardIds[0])
|
||||
}
|
||||
if decoded.Sources[0].DiskId != 1 {
|
||||
t.Errorf("expected source disk_id 1, got %d", decoded.Sources[0].DiskId)
|
||||
}
|
||||
if decoded.Targets[0].DiskId != 2 {
|
||||
t.Errorf("expected target disk_id 2, got %d", decoded.Targets[0].DiskId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsProtobufValidation(t *testing.T) {
|
||||
// Protobuf payload with missing ShardIds should fail validation
|
||||
badParams := &worker_pb.TaskParams{
|
||||
TaskId: "test-id",
|
||||
VolumeId: 100,
|
||||
Sources: []*worker_pb.TaskSource{{Node: "source:8080"}}, // no ShardIds
|
||||
Targets: []*worker_pb.TaskTarget{{Node: "target:8080", ShardIds: []uint32{3}}},
|
||||
}
|
||||
payload, _ := proto.Marshal(badParams)
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-validate",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"task_params_pb": {Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload}},
|
||||
},
|
||||
}
|
||||
_, err := decodeECBalanceTaskParams(job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing Sources[0].ShardIds in protobuf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeECBalanceTaskParamsMissingShardID(t *testing.T) {
|
||||
job := &plugin_pb.JobSpec{
|
||||
JobId: "job-3",
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 300}},
|
||||
"source_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "src:8080"}},
|
||||
"target_server": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "dst:8080"}},
|
||||
"collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "test-col"}},
|
||||
// shard_id deliberately missing
|
||||
},
|
||||
}
|
||||
|
||||
_, err := decodeECBalanceTaskParams(job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing shard_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBalanceHandlerCapability(t *testing.T) {
|
||||
handler := NewECBalanceHandler(nil)
|
||||
cap := handler.Capability()
|
||||
|
||||
if cap.JobType != "ec_balance" {
|
||||
t.Errorf("expected job type ec_balance, got %s", cap.JobType)
|
||||
}
|
||||
if !cap.CanDetect {
|
||||
t.Error("expected CanDetect=true")
|
||||
}
|
||||
if !cap.CanExecute {
|
||||
t.Error("expected CanExecute=true")
|
||||
}
|
||||
if cap.Weight != 60 {
|
||||
t.Errorf("expected weight 60, got %d", cap.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestECBalanceConfigRoundTrip(t *testing.T) {
|
||||
config := ecbalancetask.NewDefaultConfig()
|
||||
config.ImbalanceThreshold = 0.3
|
||||
config.MinServerCount = 5
|
||||
config.CollectionFilter = "my_col"
|
||||
config.DiskType = "ssd"
|
||||
config.PreferredTags = []string{"fast", "ssd"}
|
||||
|
||||
policy := config.ToTaskPolicy()
|
||||
if policy == nil {
|
||||
t.Fatal("expected non-nil policy")
|
||||
}
|
||||
|
||||
config2 := ecbalancetask.NewDefaultConfig()
|
||||
if err := config2.FromTaskPolicy(policy); err != nil {
|
||||
t.Fatalf("failed to load from policy: %v", err)
|
||||
}
|
||||
|
||||
if config2.ImbalanceThreshold != 0.3 {
|
||||
t.Errorf("expected threshold 0.3, got %f", config2.ImbalanceThreshold)
|
||||
}
|
||||
if config2.MinServerCount != 5 {
|
||||
t.Errorf("expected min_server_count 5, got %d", config2.MinServerCount)
|
||||
}
|
||||
if config2.CollectionFilter != "my_col" {
|
||||
t.Errorf("expected collection_filter my_col, got %s", config2.CollectionFilter)
|
||||
}
|
||||
if config2.DiskType != "ssd" {
|
||||
t.Errorf("expected disk_type ssd, got %s", config2.DiskType)
|
||||
}
|
||||
if len(config2.PreferredTags) != 2 || config2.PreferredTags[0] != "fast" {
|
||||
t.Errorf("expected preferred_tags [fast ssd], got %v", config2.PreferredTags)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
erasurecodingtask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
@@ -601,7 +601,8 @@ func (h *ErasureCodingHandler) collectVolumeMetrics(
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
return collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
metrics, activeTopology, _, err := collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveErasureCodingWorkerConfig(values map[string]*plugin_pb.ConfigValue) *erasureCodingWorkerConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,22 @@ const (
|
||||
defaultMaxSnapshotsToKeep = 5
|
||||
defaultOrphanOlderThanHours = 72
|
||||
defaultMaxCommitRetries = 5
|
||||
defaultTargetFileSizeBytes = 256 * 1024 * 1024
|
||||
defaultTargetFileSizeMB = 256
|
||||
defaultMinInputFiles = 5
|
||||
defaultMinManifestsToRewrite = 5
|
||||
minManifestsToRewrite = 2
|
||||
defaultOperations = "all"
|
||||
|
||||
// Metric keys returned by maintenance operations.
|
||||
MetricFilesMerged = "files_merged"
|
||||
MetricFilesWritten = "files_written"
|
||||
MetricBins = "bins"
|
||||
MetricSnapshotsExpired = "snapshots_expired"
|
||||
MetricFilesDeleted = "files_deleted"
|
||||
MetricOrphansRemoved = "orphans_removed"
|
||||
MetricManifestsRewritten = "manifests_rewritten"
|
||||
MetricEntriesTotal = "entries_total"
|
||||
MetricDurationMs = "duration_ms"
|
||||
)
|
||||
|
||||
// Config holds parsed worker config values.
|
||||
@@ -29,7 +42,9 @@ type Config struct {
|
||||
MaxCommitRetries int64
|
||||
TargetFileSizeBytes int64
|
||||
MinInputFiles int64
|
||||
MinManifestsToRewrite int64
|
||||
Operations string
|
||||
ApplyDeletes bool
|
||||
}
|
||||
|
||||
// ParseConfig extracts an iceberg maintenance Config from plugin config values.
|
||||
@@ -40,9 +55,11 @@ func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
||||
MaxSnapshotsToKeep: readInt64Config(values, "max_snapshots_to_keep", defaultMaxSnapshotsToKeep),
|
||||
OrphanOlderThanHours: readInt64Config(values, "orphan_older_than_hours", defaultOrphanOlderThanHours),
|
||||
MaxCommitRetries: readInt64Config(values, "max_commit_retries", defaultMaxCommitRetries),
|
||||
TargetFileSizeBytes: readInt64Config(values, "target_file_size_bytes", defaultTargetFileSizeBytes),
|
||||
TargetFileSizeBytes: readInt64Config(values, "target_file_size_mb", defaultTargetFileSizeMB) * 1024 * 1024,
|
||||
MinInputFiles: readInt64Config(values, "min_input_files", defaultMinInputFiles),
|
||||
MinManifestsToRewrite: readInt64Config(values, "min_manifests_to_rewrite", defaultMinManifestsToRewrite),
|
||||
Operations: readStringConfig(values, "operations", defaultOperations),
|
||||
ApplyDeletes: readBoolConfig(values, "apply_deletes", true),
|
||||
}
|
||||
|
||||
// Clamp to safe minimums using the default constants
|
||||
@@ -59,11 +76,14 @@ func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
||||
cfg.MaxCommitRetries = defaultMaxCommitRetries
|
||||
}
|
||||
if cfg.TargetFileSizeBytes <= 0 {
|
||||
cfg.TargetFileSizeBytes = defaultTargetFileSizeBytes
|
||||
cfg.TargetFileSizeBytes = defaultTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
if cfg.MinInputFiles < 2 {
|
||||
cfg.MinInputFiles = defaultMinInputFiles
|
||||
}
|
||||
if cfg.MinManifestsToRewrite < minManifestsToRewrite {
|
||||
cfg.MinManifestsToRewrite = minManifestsToRewrite
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -147,6 +167,35 @@ func readStringConfig(values map[string]*plugin_pb.ConfigValue, field string, fa
|
||||
return fallback
|
||||
}
|
||||
|
||||
// readBoolConfig reads a bool value from plugin config, with fallback.
|
||||
func readBoolConfig(values map[string]*plugin_pb.ConfigValue, field string, fallback bool) bool {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
value := values[field]
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch kind := value.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_BoolValue:
|
||||
return kind.BoolValue
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
s := strings.TrimSpace(strings.ToLower(kind.StringValue))
|
||||
if s == "true" || s == "1" || s == "yes" {
|
||||
return true
|
||||
}
|
||||
if s == "false" || s == "0" || s == "no" {
|
||||
return false
|
||||
}
|
||||
glog.V(1).Infof("readBoolConfig: unrecognized string value %q for field %q, using fallback %v", kind.StringValue, field, fallback)
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return kind.Int64Value != 0
|
||||
default:
|
||||
glog.V(1).Infof("readBoolConfig: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// readInt64Config reads an int64 value from plugin config, with fallback.
|
||||
func readInt64Config(values map[string]*plugin_pb.ConfigValue, field string, fallback int64) int64 {
|
||||
if values == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -18,11 +20,12 @@ import (
|
||||
|
||||
// tableInfo captures metadata about a table for detection/execution.
|
||||
type tableInfo struct {
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
TablePath string // namespace/tableName
|
||||
Metadata table.Metadata
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
TablePath string // namespace/tableName
|
||||
MetadataFileName string
|
||||
Metadata table.Metadata
|
||||
}
|
||||
|
||||
// scanTablesForMaintenance enumerates table buckets and their tables,
|
||||
@@ -37,13 +40,16 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
limit int,
|
||||
) ([]tableInfo, error) {
|
||||
var tables []tableInfo
|
||||
ops, err := parseOperations(config.Operations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse operations: %w", err)
|
||||
}
|
||||
|
||||
// Compile wildcard matchers once (nil = match all)
|
||||
bucketMatchers := wildcard.CompileWildcardMatchers(bucketFilter)
|
||||
nsMatchers := wildcard.CompileWildcardMatchers(namespaceFilter)
|
||||
tableMatchers := wildcard.CompileWildcardMatchers(tableFilter)
|
||||
|
||||
// List entries under /buckets to find table buckets
|
||||
bucketsPath := s3tables.TablesPath
|
||||
bucketEntries, err := listFilerEntries(ctx, filerClient, bucketsPath, "")
|
||||
if err != nil {
|
||||
@@ -117,7 +123,8 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
|
||||
// Parse the internal metadata to get FullMetadata
|
||||
var internalMeta struct {
|
||||
Metadata *struct {
|
||||
MetadataLocation string `json:"metadataLocation,omitempty"`
|
||||
Metadata *struct {
|
||||
FullMetadata json.RawMessage `json:"fullMetadata,omitempty"`
|
||||
} `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -135,13 +142,21 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
continue
|
||||
}
|
||||
|
||||
if needsMaintenance(icebergMeta, config) {
|
||||
tablePath := path.Join(nsName, tblName)
|
||||
metadataFileName := metadataFileNameFromLocation(internalMeta.MetadataLocation, bucketName, tablePath)
|
||||
needsWork, err := h.tableNeedsMaintenance(ctx, filerClient, bucketName, tablePath, icebergMeta, metadataFileName, config, ops)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot evaluate maintenance need: %v", bucketName, nsName, tblName, err)
|
||||
continue
|
||||
}
|
||||
if needsWork {
|
||||
tables = append(tables, tableInfo{
|
||||
BucketName: bucketName,
|
||||
Namespace: nsName,
|
||||
TableName: tblName,
|
||||
TablePath: path.Join(nsName, tblName),
|
||||
Metadata: icebergMeta,
|
||||
BucketName: bucketName,
|
||||
Namespace: nsName,
|
||||
TableName: tblName,
|
||||
TablePath: tablePath,
|
||||
MetadataFileName: metadataFileName,
|
||||
Metadata: icebergMeta,
|
||||
})
|
||||
if limit > 0 && len(tables) > limit {
|
||||
return tables, nil
|
||||
@@ -154,8 +169,216 @@ func (h *Handler) scanTablesForMaintenance(
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// needsMaintenance checks if a table needs any maintenance based on
|
||||
// metadata-only thresholds (no manifest reading).
|
||||
func normalizeDetectionConfig(config Config) Config {
|
||||
normalized := config
|
||||
if normalized.TargetFileSizeBytes <= 0 {
|
||||
normalized.TargetFileSizeBytes = defaultTargetFileSizeMB * 1024 * 1024
|
||||
}
|
||||
if normalized.MinInputFiles < 2 {
|
||||
normalized.MinInputFiles = defaultMinInputFiles
|
||||
}
|
||||
if normalized.MinManifestsToRewrite < minManifestsToRewrite {
|
||||
normalized.MinManifestsToRewrite = minManifestsToRewrite
|
||||
}
|
||||
if normalized.OrphanOlderThanHours <= 0 {
|
||||
normalized.OrphanOlderThanHours = defaultOrphanOlderThanHours
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (h *Handler) tableNeedsMaintenance(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
meta table.Metadata,
|
||||
metadataFileName string,
|
||||
config Config,
|
||||
ops []string,
|
||||
) (bool, error) {
|
||||
config = normalizeDetectionConfig(config)
|
||||
|
||||
// Evaluate the metadata-only expiration check first so large tables do not
|
||||
// pay for manifest reads when snapshot expiry already makes them eligible.
|
||||
for _, op := range ops {
|
||||
if op == "expire_snapshots" && needsMaintenance(meta, config) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
loadManifests := func() ([]iceberg.ManifestFile, error) {
|
||||
return loadCurrentManifests(ctx, filerClient, bucketName, tablePath, meta)
|
||||
}
|
||||
|
||||
var currentManifests []iceberg.ManifestFile
|
||||
var manifestsErr error
|
||||
manifestsLoaded := false
|
||||
getCurrentManifests := func() ([]iceberg.ManifestFile, error) {
|
||||
if manifestsLoaded {
|
||||
return currentManifests, manifestsErr
|
||||
}
|
||||
currentManifests, manifestsErr = loadManifests()
|
||||
manifestsLoaded = true
|
||||
return currentManifests, manifestsErr
|
||||
}
|
||||
var opEvalErrors []string
|
||||
|
||||
for _, op := range ops {
|
||||
switch op {
|
||||
case "expire_snapshots":
|
||||
// Handled by the metadata-only check above.
|
||||
continue
|
||||
case "compact":
|
||||
manifests, err := getCurrentManifests()
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
eligible, err := hasEligibleCompaction(ctx, filerClient, bucketName, tablePath, manifests, config)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if eligible {
|
||||
return true, nil
|
||||
}
|
||||
case "rewrite_manifests":
|
||||
manifests, err := getCurrentManifests()
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if countDataManifests(manifests) >= config.MinManifestsToRewrite {
|
||||
return true, nil
|
||||
}
|
||||
case "remove_orphans":
|
||||
if metadataFileName == "" {
|
||||
_, currentMetadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
metadataFileName = currentMetadataFileName
|
||||
}
|
||||
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, tablePath, meta, metadataFileName, config.OrphanOlderThanHours)
|
||||
if err != nil {
|
||||
opEvalErrors = append(opEvalErrors, fmt.Sprintf("%s: %v", op, err))
|
||||
continue
|
||||
}
|
||||
if len(orphanCandidates) > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(opEvalErrors) > 0 {
|
||||
return false, fmt.Errorf("evaluate maintenance operations: %s", strings.Join(opEvalErrors, "; "))
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func metadataFileNameFromLocation(location, bucketName, tablePath string) string {
|
||||
if location == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Base(normalizeIcebergPath(location, bucketName, tablePath))
|
||||
}
|
||||
|
||||
func countDataManifests(manifests []iceberg.ManifestFile) int64 {
|
||||
var count int64
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() == iceberg.ManifestContentData {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func loadCurrentManifests(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
meta table.Metadata,
|
||||
) ([]iceberg.ManifestFile, error) {
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(manifestListData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
return manifests, nil
|
||||
}
|
||||
|
||||
func hasEligibleCompaction(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
manifests []iceberg.ManifestFile,
|
||||
config Config,
|
||||
) (bool, error) {
|
||||
if len(manifests) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
minInputFiles, err := compactionMinInputFiles(config.MinInputFiles)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var dataManifests []iceberg.ManifestFile
|
||||
specIDs := make(map[int32]struct{})
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
dataManifests = append(dataManifests, mf)
|
||||
specIDs[mf.PartitionSpecID()] = struct{}{}
|
||||
}
|
||||
if len(dataManifests) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(specIDs) > 1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var allEntries []iceberg.ManifestEntry
|
||||
for _, mf := range dataManifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
allEntries = append(allEntries, entries...)
|
||||
}
|
||||
|
||||
bins := buildCompactionBins(allEntries, config.TargetFileSizeBytes, minInputFiles)
|
||||
return len(bins) > 0, nil
|
||||
}
|
||||
|
||||
func compactionMinInputFiles(minInputFiles int64) (int, error) {
|
||||
// Ensure the configured value is positive and fits into the platform's int type
|
||||
if minInputFiles <= 0 {
|
||||
return 0, fmt.Errorf("min input files must be positive, got %d", minInputFiles)
|
||||
}
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if minInputFiles > maxInt {
|
||||
return 0, fmt.Errorf("min input files %d exceeds platform int size", minInputFiles)
|
||||
}
|
||||
return int(minInputFiles), nil
|
||||
}
|
||||
|
||||
// needsMaintenance checks whether snapshot expiration work is needed based on
|
||||
// metadata-only thresholds.
|
||||
func needsMaintenance(meta table.Metadata, config Config) bool {
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,15 @@ import (
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
@@ -25,6 +28,30 @@ type filerFileEntry struct {
|
||||
Entry *filer_pb.Entry
|
||||
}
|
||||
|
||||
var initGlobalHTTPClientOnce sync.Once
|
||||
|
||||
type singleFilerClient struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (c singleFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c.client)
|
||||
}
|
||||
|
||||
func (c singleFilerClient) AdjustedUrl(location *filer_pb.Location) string {
|
||||
if location == nil {
|
||||
return ""
|
||||
}
|
||||
if location.PublicUrl != "" {
|
||||
return location.PublicUrl
|
||||
}
|
||||
return location.Url
|
||||
}
|
||||
|
||||
func (c singleFilerClient) GetDataCenter() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// listFilerEntries lists all entries in a directory.
|
||||
func listFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, prefix string) ([]*filer_pb.Entry, error) {
|
||||
var entries []*filer_pb.Entry
|
||||
@@ -174,15 +201,20 @@ func loadFileByIcebergPath(ctx context.Context, client filer_pb.SeaweedFilerClie
|
||||
return nil, fmt.Errorf("file not found: %s/%s", dir, fileName)
|
||||
}
|
||||
|
||||
// Inline content is available for small files (metadata, manifests, and
|
||||
// manifest lists written by saveFilerFile). Larger files uploaded via S3
|
||||
// are stored as chunks with empty Content — detect this and return a
|
||||
// clear error rather than silently returning empty data.
|
||||
if len(resp.Entry.Content) == 0 && len(resp.Entry.Chunks) > 0 {
|
||||
return nil, fmt.Errorf("file %s/%s is stored in chunks; only inline content is supported", dir, fileName)
|
||||
if len(resp.Entry.Content) > 0 || len(resp.Entry.Chunks) == 0 {
|
||||
return resp.Entry.Content, nil
|
||||
}
|
||||
|
||||
return resp.Entry.Content, nil
|
||||
initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
|
||||
reader := filer.NewFileReader(singleFilerClient{client: client}, resp.Entry)
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read chunked file %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// normalizeIcebergPath converts an Iceberg path (which may be an S3 URL, an
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
@@ -31,6 +33,8 @@ type Handler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
const filerConnectTimeout = 5 * time.Second
|
||||
|
||||
// NewHandler creates a new handler for iceberg table maintenance.
|
||||
func NewHandler(grpcDialOption grpc.DialOption) *Handler {
|
||||
return &Handler{grpcDialOption: grpcDialOption}
|
||||
@@ -133,12 +137,12 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
Description: "Controls for bin-packing small Parquet data files.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "target_file_size_bytes",
|
||||
Label: "Target File Size (bytes)",
|
||||
Description: "Files smaller than this are candidates for compaction.",
|
||||
Name: "target_file_size_mb",
|
||||
Label: "Target File Size (MB)",
|
||||
Description: "Files smaller than this (in megabytes) are candidates for compaction.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1024 * 1024}},
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "min_input_files",
|
||||
@@ -148,6 +152,13 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
{
|
||||
Name: "apply_deletes",
|
||||
Label: "Apply Deletes",
|
||||
Description: "When true, compaction applies position and equality deletes to data files. When false, tables with delete manifests are skipped.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_BOOL,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TOGGLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -165,6 +176,21 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "manifests",
|
||||
Title: "Manifest Rewriting",
|
||||
Description: "Controls for merging small manifests.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "min_manifests_to_rewrite",
|
||||
Label: "Min Manifests",
|
||||
Description: "Minimum number of manifests before rewriting is triggered.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "general",
|
||||
Title: "General",
|
||||
@@ -190,13 +216,15 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_bytes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeBytes}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"min_manifests_to_rewrite": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinManifestsToRewrite}},
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
@@ -211,13 +239,14 @@ func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
JobTypeMaxRuntimeSeconds: 3600, // 1 hour max
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_bytes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeBytes}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"target_file_size_mb": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeMB}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: true}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -255,11 +284,10 @@ func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionReq
|
||||
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
|
||||
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
|
||||
|
||||
// Connect to filer to scan table buckets
|
||||
filerAddress := filerAddresses[0]
|
||||
conn, err := grpc.NewClient(filerAddress, h.grpcDialOption)
|
||||
// Connect to filer — try each address until one succeeds.
|
||||
filerAddress, conn, err := h.connectToFiler(ctx, filerAddresses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerAddress, err)
|
||||
return fmt.Errorf("connect to filer: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||
@@ -329,6 +357,12 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
if bucketName == "" || namespace == "" || tableName == "" || filerAddress == "" {
|
||||
return fmt.Errorf("missing required parameters: bucket_name=%q, namespace=%q, table_name=%q, filer_address=%q", bucketName, namespace, tableName, filerAddress)
|
||||
}
|
||||
// Reject path traversal in bucket/namespace/table names.
|
||||
for _, name := range []string{bucketName, namespace, tableName} {
|
||||
if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") {
|
||||
return fmt.Errorf("invalid name %q: must not contain path separators or '..'", name)
|
||||
}
|
||||
}
|
||||
if tablePath == "" {
|
||||
tablePath = path.Join(namespace, tableName)
|
||||
}
|
||||
@@ -361,7 +395,7 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
}
|
||||
|
||||
// Connect to filer
|
||||
conn, err := grpc.NewClient(filerAddress, h.grpcDialOption)
|
||||
conn, err := h.dialFiler(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerAddress, err)
|
||||
}
|
||||
@@ -372,6 +406,7 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
var lastErr error
|
||||
totalOps := len(ops)
|
||||
completedOps := 0
|
||||
allMetrics := make(map[string]int64)
|
||||
|
||||
// Execute operations in correct Iceberg maintenance order:
|
||||
// expire_snapshots → remove_orphans → rewrite_manifests
|
||||
@@ -399,21 +434,37 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
|
||||
var opResult string
|
||||
var opErr error
|
||||
var opMetrics map[string]int64
|
||||
|
||||
switch op {
|
||||
case "compact":
|
||||
opResult, opErr = h.compactDataFiles(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
opResult, opMetrics, opErr = h.compactDataFiles(ctx, filerClient, bucketName, tablePath, workerConfig, func(binIdx, totalBins int) {
|
||||
binProgress := progress + float64(binIdx+1)/float64(totalBins)*(100.0/float64(totalOps))
|
||||
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: binProgress,
|
||||
Stage: fmt.Sprintf("compact bin %d/%d", binIdx+1, totalBins),
|
||||
Message: fmt.Sprintf("compacting bin %d of %d", binIdx+1, totalBins),
|
||||
})
|
||||
})
|
||||
case "expire_snapshots":
|
||||
opResult, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
opResult, opMetrics, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "remove_orphans":
|
||||
opResult, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
opResult, opMetrics, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "rewrite_manifests":
|
||||
opResult, opErr = h.rewriteManifests(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
opResult, opMetrics, opErr = h.rewriteManifests(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
default:
|
||||
glog.Warningf("unknown maintenance operation: %s", op)
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate per-operation metrics with dot-prefixed keys
|
||||
for k, v := range opMetrics {
|
||||
allMetrics[op+"."+k] = v
|
||||
}
|
||||
|
||||
completedOps++
|
||||
if opErr != nil {
|
||||
glog.Warningf("iceberg maintenance %s failed for %s/%s/%s: %v", op, bucketName, namespace, tableName, opErr)
|
||||
@@ -427,6 +478,16 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
resultSummary := strings.Join(results, "; ")
|
||||
success := lastErr == nil
|
||||
|
||||
// Build OutputValues with base table info + per-operation metrics
|
||||
outputValues := map[string]*plugin_pb.ConfigValue{
|
||||
"bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: namespace}},
|
||||
"table": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: tableName}},
|
||||
}
|
||||
for k, v := range allMetrics {
|
||||
outputValues[k] = &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: v}}
|
||||
}
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
@@ -438,12 +499,8 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
|
||||
return ""
|
||||
}(),
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: namespace}},
|
||||
"table": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: tableName}},
|
||||
},
|
||||
Summary: resultSummary,
|
||||
OutputValues: outputValues,
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
@@ -467,5 +524,41 @@ func (h *Handler) sendEmptyDetection(sender pluginworker.DetectionSender) error
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) dialFiler(ctx context.Context, address string) (*grpc.ClientConn, error) {
|
||||
opCtx, opCancel := context.WithTimeout(ctx, filerConnectTimeout)
|
||||
defer opCancel()
|
||||
|
||||
conn, err := pb.GrpcDial(opCtx, address, false, h.grpcDialOption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := filer_pb.NewSeaweedFilerClient(conn)
|
||||
if _, err := client.Ping(opCtx, &filer_pb.PingRequest{}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// connectToFiler tries each filer address in order and returns the first
|
||||
// address whose gRPC connection and Ping request succeed.
|
||||
func (h *Handler) connectToFiler(ctx context.Context, addresses []string) (string, *grpc.ClientConn, error) {
|
||||
var lastErr error
|
||||
for _, addr := range addresses {
|
||||
conn, err := h.dialFiler(ctx, addr)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("filer %s: %w", addr, err)
|
||||
continue
|
||||
}
|
||||
return addr, conn, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no filer addresses provided")
|
||||
}
|
||||
return "", nil, lastErr
|
||||
}
|
||||
|
||||
// Ensure Handler implements JobHandler.
|
||||
var _ pluginworker.JobHandler = (*Handler)(nil)
|
||||
|
||||
@@ -2,13 +2,18 @@ package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
@@ -363,11 +368,11 @@ func TestManifestRewriteNestedPathConsistency(t *testing.T) {
|
||||
|
||||
func TestNormalizeIcebergPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
icebergPath string
|
||||
bucket string
|
||||
tablePath string
|
||||
expected string
|
||||
bucket string
|
||||
tablePath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"relative metadata path",
|
||||
@@ -500,13 +505,19 @@ func TestBuildCompactionBinsMultiplePartitions(t *testing.T) {
|
||||
|
||||
partA := map[int]any{1: "us-east"}
|
||||
partB := map[int]any{1: "eu-west"}
|
||||
partitionSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
|
||||
SourceID: 1,
|
||||
FieldID: 1000,
|
||||
Name: "region",
|
||||
Transform: iceberg.IdentityTransform{},
|
||||
})
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/a1.parquet", size: 1024, partition: partA},
|
||||
{path: "data/a2.parquet", size: 2048, partition: partA},
|
||||
{path: "data/b1.parquet", size: 1024, partition: partB},
|
||||
{path: "data/b2.parquet", size: 2048, partition: partB},
|
||||
{path: "data/b3.parquet", size: 4096, partition: partB},
|
||||
{path: "data/a1.parquet", size: 1024, partition: partA, partitionSpec: &partitionSpec},
|
||||
{path: "data/a2.parquet", size: 2048, partition: partA, partitionSpec: &partitionSpec},
|
||||
{path: "data/b1.parquet", size: 1024, partition: partB, partitionSpec: &partitionSpec},
|
||||
{path: "data/b2.parquet", size: 2048, partition: partB, partitionSpec: &partitionSpec},
|
||||
{path: "data/b3.parquet", size: 4096, partition: partB, partitionSpec: &partitionSpec},
|
||||
})
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
@@ -515,28 +526,142 @@ func TestBuildCompactionBinsMultiplePartitions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type testEntrySpec struct {
|
||||
path string
|
||||
size int64
|
||||
partition map[int]any
|
||||
func TestSplitOversizedBinRespectsTargetSize(t *testing.T) {
|
||||
targetSize := int64(100)
|
||||
minFiles := 2
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 80, partition: map[int]any{}},
|
||||
{path: "data/f2.parquet", size: 80, partition: map[int]any{}},
|
||||
{path: "data/f3.parquet", size: 10, partition: map[int]any{}},
|
||||
{path: "data/f4.parquet", size: 10, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := splitOversizedBin(compactionBin{
|
||||
PartitionKey: "__unpartitioned__",
|
||||
Partition: map[int]any{},
|
||||
Entries: entries,
|
||||
TotalSize: 180,
|
||||
}, targetSize, minFiles)
|
||||
|
||||
if len(bins) == 0 {
|
||||
t.Fatal("expected at least one valid bin")
|
||||
}
|
||||
for i, bin := range bins {
|
||||
if bin.TotalSize > targetSize {
|
||||
t.Fatalf("bin %d exceeds target size: got %d want <= %d", i, bin.TotalSize, targetSize)
|
||||
}
|
||||
if len(bin.Entries) < minFiles {
|
||||
t.Fatalf("bin %d does not meet minFiles: got %d want >= %d", i, len(bin.Entries), minFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntry {
|
||||
func TestSplitOversizedBinDropsImpossibleRunts(t *testing.T) {
|
||||
targetSize := int64(100)
|
||||
minFiles := 2
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 60, partition: map[int]any{}},
|
||||
{path: "data/f2.parquet", size: 60, partition: map[int]any{}},
|
||||
{path: "data/f3.parquet", size: 60, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := splitOversizedBin(compactionBin{
|
||||
PartitionKey: "__unpartitioned__",
|
||||
Partition: map[int]any{},
|
||||
Entries: entries,
|
||||
TotalSize: 180,
|
||||
}, targetSize, minFiles)
|
||||
|
||||
if len(bins) != 0 {
|
||||
t.Fatalf("expected no valid bins, got %d", len(bins))
|
||||
}
|
||||
}
|
||||
|
||||
type testEntrySpec struct {
|
||||
path string
|
||||
size int64
|
||||
partition map[int]any
|
||||
partitionSpec *iceberg.PartitionSpec
|
||||
specID int32 // partition spec ID; 0 uses UnpartitionedSpec
|
||||
}
|
||||
|
||||
func buildTestDataFile(t *testing.T, spec testEntrySpec) iceberg.DataFile {
|
||||
t.Helper()
|
||||
|
||||
partitionSpec := iceberg.UnpartitionedSpec
|
||||
if spec.partitionSpec != nil {
|
||||
partitionSpec = spec.partitionSpec
|
||||
} else if len(spec.partition) > 0 {
|
||||
t.Fatalf("partition spec is required for partitioned test entry %s", spec.path)
|
||||
}
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
*partitionSpec,
|
||||
iceberg.EntryContentData,
|
||||
spec.path,
|
||||
iceberg.ParquetFile,
|
||||
spec.partition,
|
||||
nil, nil,
|
||||
1, // recordCount (must be > 0)
|
||||
spec.size,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file %s: %v", spec.path, err)
|
||||
}
|
||||
return dfBuilder.Build()
|
||||
}
|
||||
|
||||
func makeManifestEntriesWithSnapshot(
|
||||
t *testing.T,
|
||||
specs []testEntrySpec,
|
||||
snapshotID int64,
|
||||
status iceberg.ManifestEntryStatus,
|
||||
) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
*iceberg.UnpartitionedSpec,
|
||||
iceberg.EntryContentData,
|
||||
spec.path,
|
||||
iceberg.ParquetFile,
|
||||
spec.partition,
|
||||
entries = append(entries, iceberg.NewManifestEntry(
|
||||
status,
|
||||
&snapshotID,
|
||||
nil, nil,
|
||||
1, // recordCount (must be > 0)
|
||||
spec.size,
|
||||
buildTestDataFile(t, spec),
|
||||
))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// makeTestEntries creates manifest entries using the default unpartitioned
|
||||
// spec. For multi-spec testing, use makeTestEntriesWithSpec instead.
|
||||
func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
return makeManifestEntriesWithSnapshot(t, specs, 1, iceberg.EntryStatusADDED)
|
||||
}
|
||||
|
||||
// makeTestEntriesWithSpec creates manifest entries using specific partition specs.
|
||||
// Each spec in the specs slice can specify a specID; the entry is built using
|
||||
// a PartitionSpec with that ID.
|
||||
func makeTestEntriesWithSpec(t *testing.T, specs []testEntrySpec, partSpecs map[int32]iceberg.PartitionSpec) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
ps, ok := partSpecs[s.specID]
|
||||
if !ok {
|
||||
t.Fatalf("spec ID %d not found in partSpecs map", s.specID)
|
||||
}
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
ps,
|
||||
iceberg.EntryContentData,
|
||||
s.path,
|
||||
iceberg.ParquetFile,
|
||||
s.partition,
|
||||
nil, nil,
|
||||
1,
|
||||
s.size,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file %s: %v", spec.path, err)
|
||||
t.Fatalf("failed to build data file %s: %v", s.path, err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
@@ -545,6 +670,440 @@ func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntr
|
||||
return entries
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsMultipleSpecs(t *testing.T) {
|
||||
targetSize := int64(256 * 1024 * 1024)
|
||||
minFiles := 2
|
||||
|
||||
// Two partition specs with different IDs
|
||||
spec0 := iceberg.NewPartitionSpecID(0)
|
||||
spec1 := iceberg.NewPartitionSpecID(1)
|
||||
partSpecs := map[int32]iceberg.PartitionSpec{
|
||||
0: spec0,
|
||||
1: spec1,
|
||||
}
|
||||
|
||||
// Entries from two different specs with same partition values should go
|
||||
// to separate bins.
|
||||
entries := makeTestEntriesWithSpec(t, []testEntrySpec{
|
||||
{path: "data/s0-f1.parquet", size: 1024, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/s0-f2.parquet", size: 2048, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/s1-f1.parquet", size: 1024, partition: map[int]any{}, specID: 1},
|
||||
{path: "data/s1-f2.parquet", size: 2048, partition: map[int]any{}, specID: 1},
|
||||
}, partSpecs)
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 2 {
|
||||
t.Fatalf("expected 2 bins (one per spec), got %d", len(bins))
|
||||
}
|
||||
|
||||
// Verify each bin has entries from only one spec
|
||||
specsSeen := make(map[int32]bool)
|
||||
for _, bin := range bins {
|
||||
specsSeen[bin.SpecID] = true
|
||||
for _, entry := range bin.Entries {
|
||||
if entry.DataFile().SpecID() != bin.SpecID {
|
||||
t.Errorf("bin specID=%d contains entry with specID=%d", bin.SpecID, entry.DataFile().SpecID())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !specsSeen[0] || !specsSeen[1] {
|
||||
t.Errorf("expected bins for spec 0 and 1, got specs %v", specsSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsSingleSpec(t *testing.T) {
|
||||
// Verify existing behavior: all entries with spec 0 still group correctly.
|
||||
targetSize := int64(256 * 1024 * 1024)
|
||||
minFiles := 2
|
||||
|
||||
spec0 := iceberg.NewPartitionSpecID(0)
|
||||
partSpecs := map[int32]iceberg.PartitionSpec{0: spec0}
|
||||
|
||||
entries := makeTestEntriesWithSpec(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 1024, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/f2.parquet", size: 2048, partition: map[int]any{}, specID: 0},
|
||||
{path: "data/f3.parquet", size: 4096, partition: map[int]any{}, specID: 0},
|
||||
}, partSpecs)
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 1 {
|
||||
t.Fatalf("expected 1 bin, got %d", len(bins))
|
||||
}
|
||||
if len(bins[0].Entries) != 3 {
|
||||
t.Errorf("expected 3 entries in bin, got %d", len(bins[0].Entries))
|
||||
}
|
||||
if bins[0].SpecID != 0 {
|
||||
t.Errorf("expected specID=0, got %d", bins[0].SpecID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigApplyDeletes(t *testing.T) {
|
||||
// Default: true
|
||||
config := ParseConfig(nil)
|
||||
if !config.ApplyDeletes {
|
||||
t.Error("expected ApplyDeletes=true by default")
|
||||
}
|
||||
|
||||
// Explicit false
|
||||
config = ParseConfig(map[string]*plugin_pb.ConfigValue{
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: false}},
|
||||
})
|
||||
if config.ApplyDeletes {
|
||||
t.Error("expected ApplyDeletes=false when explicitly set")
|
||||
}
|
||||
|
||||
// String "false"
|
||||
config = ParseConfig(map[string]*plugin_pb.ConfigValue{
|
||||
"apply_deletes": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "false"}},
|
||||
})
|
||||
if config.ApplyDeletes {
|
||||
t.Error("expected ApplyDeletes=false when set via string 'false'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPositionDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
// Create a position delete Parquet file with file_path + pos columns
|
||||
type posDeleteRow struct {
|
||||
FilePath string `parquet:"file_path"`
|
||||
Pos int64 `parquet:"pos"`
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewWriter(&buf, parquet.SchemaOf(new(posDeleteRow)))
|
||||
rows := []posDeleteRow{
|
||||
{"data/file1.parquet", 0},
|
||||
{"data/file1.parquet", 5},
|
||||
{"data/file1.parquet", 10},
|
||||
{"data/file2.parquet", 3},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := writer.Write(&r); err != nil {
|
||||
t.Fatalf("write pos delete row: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close pos delete writer: %v", err)
|
||||
}
|
||||
|
||||
// Store in fake filer
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, "pos-delete-1.parquet", &filer_pb.Entry{
|
||||
Name: "pos-delete-1.parquet",
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
|
||||
// Create a manifest entry for the position delete file
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentPosDeletes,
|
||||
"data/pos-delete-1.parquet",
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
int64(len(rows)),
|
||||
int64(buf.Len()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build pos delete data file: %v", err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
|
||||
result, err := collectPositionDeletes(context.Background(), client, "test-bucket", "ns/tbl", []iceberg.ManifestEntry{entry})
|
||||
if err != nil {
|
||||
t.Fatalf("collectPositionDeletes: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(result["data/file1.parquet"]) != 3 {
|
||||
t.Errorf("expected 3 positions for file1, got %d", len(result["data/file1.parquet"]))
|
||||
}
|
||||
if len(result["data/file2.parquet"]) != 1 {
|
||||
t.Errorf("expected 1 position for file2, got %d", len(result["data/file2.parquet"]))
|
||||
}
|
||||
|
||||
// Verify sorted
|
||||
positions := result["data/file1.parquet"]
|
||||
for i := 1; i < len(positions); i++ {
|
||||
if positions[i] <= positions[i-1] {
|
||||
t.Errorf("positions not sorted: %v", positions)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectEqualityDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
// Create an equality delete Parquet file with id + name columns
|
||||
type eqDeleteRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer := parquet.NewWriter(&buf, parquet.SchemaOf(new(eqDeleteRow)))
|
||||
deleteRows := []eqDeleteRow{
|
||||
{1, "alice"},
|
||||
{2, "bob"},
|
||||
{3, "charlie"},
|
||||
}
|
||||
for _, r := range deleteRows {
|
||||
if err := writer.Write(&r); err != nil {
|
||||
t.Fatalf("write eq delete row: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close eq delete writer: %v", err)
|
||||
}
|
||||
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, "eq-delete-1.parquet", &filer_pb.Entry{
|
||||
Name: "eq-delete-1.parquet",
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
|
||||
// Create manifest entry with equality field IDs
|
||||
schema := newTestSchema() // has field 1=id, 2=name
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentEqDeletes,
|
||||
"data/eq-delete-1.parquet",
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
int64(len(deleteRows)),
|
||||
int64(buf.Len()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build eq delete data file: %v", err)
|
||||
}
|
||||
dfBuilder.EqualityFieldIDs([]int{1, 2}) // id + name
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
|
||||
groups, err := collectEqualityDeletes(context.Background(), client, "test-bucket", "ns/tbl", []iceberg.ManifestEntry{entry}, schema)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEqualityDeletes: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("expected 1 equality delete group, got %d", len(groups))
|
||||
}
|
||||
if len(groups[0].Keys) != 3 {
|
||||
t.Errorf("expected 3 equality delete keys, got %d", len(groups[0].Keys))
|
||||
}
|
||||
if len(groups[0].FieldIDs) != 2 {
|
||||
t.Errorf("expected 2 field IDs, got %d", len(groups[0].FieldIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeParquetFilesWithPositionDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
// Create two data files with known rows
|
||||
type dataRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
|
||||
writeDataFile := func(name string, rows []dataRow) {
|
||||
var buf bytes.Buffer
|
||||
w := parquet.NewWriter(&buf, parquet.SchemaOf(new(dataRow)))
|
||||
for _, r := range rows {
|
||||
if err := w.Write(&r); err != nil {
|
||||
t.Fatalf("write row: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, name, &filer_pb.Entry{
|
||||
Name: name,
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
writeDataFile("file1.parquet", []dataRow{
|
||||
{1, "alice"}, {2, "bob"}, {3, "charlie"}, {4, "dave"}, {5, "eve"},
|
||||
})
|
||||
writeDataFile("file2.parquet", []dataRow{
|
||||
{6, "frank"}, {7, "grace"},
|
||||
})
|
||||
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
makeEntry := func(path string, size int64) iceberg.ManifestEntry {
|
||||
dfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, path, iceberg.ParquetFile, map[int]any{}, nil, nil, 1, size)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
return iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfb.Build())
|
||||
}
|
||||
|
||||
entries := []iceberg.ManifestEntry{
|
||||
makeEntry("data/file1.parquet", 1024),
|
||||
makeEntry("data/file2.parquet", 512),
|
||||
}
|
||||
|
||||
// Delete rows 1 (bob) and 3 (dave) from file1
|
||||
posDeletes := map[string][]int64{
|
||||
"data/file1.parquet": {1, 3},
|
||||
}
|
||||
|
||||
merged, count, err := mergeParquetFiles(
|
||||
context.Background(), client, "test-bucket", "ns/tbl",
|
||||
entries, posDeletes, nil, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeParquetFiles: %v", err)
|
||||
}
|
||||
|
||||
// Original: 5 + 2 = 7 rows, deleted 2 = 5 rows
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 rows after position deletes, got %d", count)
|
||||
}
|
||||
|
||||
// Verify merged output is valid Parquet
|
||||
reader := parquet.NewReader(bytes.NewReader(merged))
|
||||
defer reader.Close()
|
||||
var outputRows []dataRow
|
||||
for {
|
||||
var r dataRow
|
||||
err := reader.Read(&r)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read merged row: %v", err)
|
||||
}
|
||||
outputRows = append(outputRows, r)
|
||||
}
|
||||
|
||||
if len(outputRows) != 5 {
|
||||
t.Fatalf("expected 5 output rows, got %d", len(outputRows))
|
||||
}
|
||||
|
||||
// Verify bob (id=2) and dave (id=4) are NOT in output
|
||||
for _, r := range outputRows {
|
||||
if r.ID == 2 || r.ID == 4 {
|
||||
t.Errorf("row with id=%d should have been deleted", r.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeParquetFilesWithEqualityDeletes(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
type dataRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := parquet.NewWriter(&buf, parquet.SchemaOf(new(dataRow)))
|
||||
dataRows := []dataRow{
|
||||
{1, "alice"}, {2, "bob"}, {3, "charlie"}, {4, "dave"},
|
||||
}
|
||||
for _, r := range dataRows {
|
||||
if err := w.Write(&r); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
dataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(dataDir, "data1.parquet", &filer_pb.Entry{
|
||||
Name: "data1.parquet",
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
dfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, "data/data1.parquet", iceberg.ParquetFile, map[int]any{}, nil, nil, 4, int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entries := []iceberg.ManifestEntry{
|
||||
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfb.Build()),
|
||||
}
|
||||
|
||||
// Build equality deletes via the collect pipeline so keys match the
|
||||
// encoding used by mergeParquetFiles.
|
||||
schema := newTestSchema() // field 1=id, field 2=name
|
||||
|
||||
type eqDeleteRow struct {
|
||||
ID int64 `parquet:"id"`
|
||||
Name string `parquet:"name"`
|
||||
}
|
||||
var eqBuf bytes.Buffer
|
||||
eqWriter := parquet.NewWriter(&eqBuf, parquet.SchemaOf(new(eqDeleteRow)))
|
||||
for _, r := range []eqDeleteRow{{0, "bob"}, {0, "dave"}} {
|
||||
if err := eqWriter.Write(&r); err != nil {
|
||||
t.Fatalf("write eq delete: %v", err)
|
||||
}
|
||||
}
|
||||
if err := eqWriter.Close(); err != nil {
|
||||
t.Fatalf("close eq writer: %v", err)
|
||||
}
|
||||
eqDataDir := "/buckets/test-bucket/ns/tbl/data"
|
||||
fs.putEntry(eqDataDir, "eq-del.parquet", &filer_pb.Entry{
|
||||
Name: "eq-del.parquet", Content: eqBuf.Bytes(),
|
||||
})
|
||||
|
||||
eqDfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentEqDeletes,
|
||||
"data/eq-del.parquet", iceberg.ParquetFile, map[int]any{}, nil, nil, 2, int64(eqBuf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("build eq delete: %v", err)
|
||||
}
|
||||
eqDfb.EqualityFieldIDs([]int{2}) // field 2 = name
|
||||
eqEntry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, eqDfb.Build())
|
||||
|
||||
eqGroups, err := collectEqualityDeletes(context.Background(), client, "test-bucket", "ns/tbl",
|
||||
[]iceberg.ManifestEntry{eqEntry}, schema)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEqualityDeletes: %v", err)
|
||||
}
|
||||
|
||||
merged, count, err := mergeParquetFiles(
|
||||
context.Background(), client, "test-bucket", "ns/tbl",
|
||||
entries, nil, eqGroups, schema,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeParquetFiles: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 rows after equality deletes, got %d", count)
|
||||
}
|
||||
|
||||
reader := parquet.NewReader(bytes.NewReader(merged))
|
||||
defer reader.Close()
|
||||
var outputRows []dataRow
|
||||
for {
|
||||
var r dataRow
|
||||
err := reader.Read(&r)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
outputRows = append(outputRows, r)
|
||||
}
|
||||
|
||||
for _, r := range outputRows {
|
||||
if r.Name == "bob" || r.Name == "dave" {
|
||||
t.Errorf("row %q should have been deleted", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectNilRequest(t *testing.T) {
|
||||
handler := NewHandler(nil)
|
||||
err := handler.Detect(nil, nil, nil)
|
||||
|
||||
@@ -38,16 +38,17 @@ func (h *Handler) expireSnapshots(
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
return "no snapshots", nil
|
||||
return "no snapshots", nil, nil
|
||||
}
|
||||
|
||||
// Determine which snapshots to expire
|
||||
@@ -92,7 +93,7 @@ func (h *Handler) expireSnapshots(
|
||||
}
|
||||
|
||||
if len(toExpire) == 0 {
|
||||
return "no snapshots expired", nil
|
||||
return "no snapshots expired", nil, nil
|
||||
}
|
||||
|
||||
// Split snapshots into expired and kept sets
|
||||
@@ -113,11 +114,11 @@ func (h *Handler) expireSnapshots(
|
||||
// This lets us determine which files become unreferenced.
|
||||
expiredFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, expiredSnaps)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect expired snapshot files: %w", err)
|
||||
return "", nil, fmt.Errorf("collect expired snapshot files: %w", err)
|
||||
}
|
||||
keptFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, keptSnaps)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect kept snapshot files: %w", err)
|
||||
return "", nil, fmt.Errorf("collect kept snapshot files: %w", err)
|
||||
}
|
||||
|
||||
// Normalize kept file paths for consistent comparison
|
||||
@@ -136,7 +137,7 @@ func (h *Handler) expireSnapshots(
|
||||
return builder.RemoveSnapshots(toExpire)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit snapshot expiration: %w", err)
|
||||
return "", nil, fmt.Errorf("commit snapshot expiration: %w", err)
|
||||
}
|
||||
|
||||
// Delete files exclusively referenced by expired snapshots (best-effort)
|
||||
@@ -156,7 +157,12 @@ func (h *Handler) expireSnapshots(
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("expired %d snapshot(s), deleted %d unreferenced file(s)", len(toExpire), deletedCount), nil
|
||||
metrics := map[string]int64{
|
||||
MetricSnapshotsExpired: int64(len(toExpire)),
|
||||
MetricFilesDeleted: int64(deletedCount),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("expired %d snapshot(s), deleted %d unreferenced file(s)", len(toExpire), deletedCount), metrics, nil
|
||||
}
|
||||
|
||||
// collectSnapshotFiles returns all file paths (manifest lists, manifest files,
|
||||
@@ -215,38 +221,62 @@ func (h *Handler) removeOrphans(
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
// Collect all referenced files from all snapshots
|
||||
orphanCandidates, err := collectOrphanCandidates(ctx, filerClient, bucketName, tablePath, meta, metadataFileName, config.OrphanOlderThanHours)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("collect orphan candidates: %w", err)
|
||||
}
|
||||
|
||||
orphanCount := 0
|
||||
for _, candidate := range orphanCandidates {
|
||||
if delErr := deleteFilerFile(ctx, filerClient, candidate.Dir, candidate.Entry.Name); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete orphan %s/%s: %v", candidate.Dir, candidate.Entry.Name, delErr)
|
||||
} else {
|
||||
orphanCount++
|
||||
}
|
||||
}
|
||||
|
||||
metrics := map[string]int64{
|
||||
MetricOrphansRemoved: int64(orphanCount),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("removed %d orphan file(s)", orphanCount), metrics, nil
|
||||
}
|
||||
|
||||
func collectOrphanCandidates(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
meta table.Metadata,
|
||||
metadataFileName string,
|
||||
orphanOlderThanHours int64,
|
||||
) ([]filerFileEntry, error) {
|
||||
referencedFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, meta.Snapshots())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect referenced files: %w", err)
|
||||
return nil, fmt.Errorf("collect referenced files: %w", err)
|
||||
}
|
||||
|
||||
// Reference the active metadata file so it is not treated as orphan
|
||||
referencedFiles[path.Join("metadata", metadataFileName)] = struct{}{}
|
||||
|
||||
// Also reference the current metadata files
|
||||
for mle := range meta.PreviousFiles() {
|
||||
referencedFiles[mle.MetadataFile] = struct{}{}
|
||||
}
|
||||
|
||||
// Precompute a normalized lookup set so orphan checks are O(1) per file.
|
||||
normalizedRefs := make(map[string]struct{}, len(referencedFiles))
|
||||
for ref := range referencedFiles {
|
||||
normalizedRefs[ref] = struct{}{}
|
||||
normalizedRefs[normalizeIcebergPath(ref, bucketName, tablePath)] = struct{}{}
|
||||
}
|
||||
|
||||
// List actual files on filer in metadata/ and data/ directories
|
||||
tableBasePath := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
safetyThreshold := time.Now().Add(-time.Duration(config.OrphanOlderThanHours) * time.Hour)
|
||||
orphanCount := 0
|
||||
safetyThreshold := time.Now().Add(-time.Duration(orphanOlderThanHours) * time.Hour)
|
||||
var candidates []filerFileEntry
|
||||
|
||||
for _, subdir := range []string{"metadata", "data"} {
|
||||
dirPath := path.Join(tableBasePath, subdir)
|
||||
@@ -258,35 +288,22 @@ func (h *Handler) removeOrphans(
|
||||
|
||||
for _, fe := range fileEntries {
|
||||
entry := fe.Entry
|
||||
// Build relative path from the table base (e.g. "data/region=us/file.parquet")
|
||||
fullPath := path.Join(fe.Dir, entry.Name)
|
||||
relPath := strings.TrimPrefix(fullPath, tableBasePath+"/")
|
||||
|
||||
_, isReferenced := normalizedRefs[relPath]
|
||||
|
||||
if isReferenced {
|
||||
if _, isReferenced := normalizedRefs[relPath]; isReferenced {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check safety window — skip entries with unknown age
|
||||
if entry.Attributes == nil {
|
||||
continue
|
||||
}
|
||||
mtime := time.Unix(entry.Attributes.Mtime, 0)
|
||||
if mtime.After(safetyThreshold) {
|
||||
if time.Unix(entry.Attributes.Mtime, 0).After(safetyThreshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete orphan
|
||||
if delErr := deleteFilerFile(ctx, filerClient, fe.Dir, entry.Name); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete orphan %s/%s: %v", fe.Dir, entry.Name, delErr)
|
||||
} else {
|
||||
orphanCount++
|
||||
}
|
||||
candidates = append(candidates, fe)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("removed %d orphan file(s)", orphanCount), nil
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -299,31 +316,41 @@ func (h *Handler) rewriteManifests(
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
) (string, map[string]int64, error) {
|
||||
start := time.Now()
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
return "", nil, fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return "no current snapshot", nil
|
||||
return "no current snapshot", nil, nil
|
||||
}
|
||||
|
||||
// Read manifest list
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(manifestListData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
|
||||
if int64(len(manifests)) < config.MinInputFiles {
|
||||
return fmt.Sprintf("only %d manifests, below threshold of %d", len(manifests), config.MinInputFiles), nil
|
||||
// Separate data manifests from delete manifests. Only data manifests
|
||||
// are candidates for rewriting; delete manifests are carried forward.
|
||||
var dataManifests []iceberg.ManifestFile
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() == iceberg.ManifestContentData {
|
||||
dataManifests = append(dataManifests, mf)
|
||||
}
|
||||
}
|
||||
|
||||
if int64(len(dataManifests)) < config.MinManifestsToRewrite {
|
||||
return fmt.Sprintf("only %d data manifests, below threshold of %d", len(dataManifests), config.MinManifestsToRewrite), nil, nil
|
||||
}
|
||||
|
||||
// Collect all entries from data manifests, grouped by partition spec ID
|
||||
@@ -341,17 +368,14 @@ func (h *Handler) rewriteManifests(
|
||||
specByID[ps.ID()] = ps
|
||||
}
|
||||
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
for _, mf := range dataManifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
return "", nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
return "", nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
|
||||
sid := mf.PartitionSpecID()
|
||||
@@ -359,7 +383,7 @@ func (h *Handler) rewriteManifests(
|
||||
if !ok {
|
||||
ps, found := specByID[int(sid)]
|
||||
if !found {
|
||||
return "", fmt.Errorf("partition spec %d not found in table metadata", sid)
|
||||
return "", nil, fmt.Errorf("partition spec %d not found in table metadata", sid)
|
||||
}
|
||||
se = &specEntries{specID: sid, spec: ps}
|
||||
specMap[sid] = se
|
||||
@@ -368,7 +392,7 @@ func (h *Handler) rewriteManifests(
|
||||
}
|
||||
|
||||
if len(specMap) == 0 {
|
||||
return "no data entries to rewrite", nil
|
||||
return "no data entries to rewrite", nil, nil
|
||||
}
|
||||
|
||||
schema := meta.CurrentSchema()
|
||||
@@ -376,6 +400,7 @@ func (h *Handler) rewriteManifests(
|
||||
snapshotID := currentSnap.SnapshotID
|
||||
newSnapshotID := time.Now().UnixMilli()
|
||||
newSeqNum := currentSnap.SequenceNumber + 1
|
||||
artifactSuffix := compactRandomSuffix()
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, tablePath, "metadata")
|
||||
|
||||
// Track written artifacts so we can clean them up if the commit fails.
|
||||
@@ -403,7 +428,7 @@ func (h *Handler) rewriteManifests(
|
||||
totalEntries := 0
|
||||
for _, se := range specMap {
|
||||
totalEntries += len(se.entries)
|
||||
manifestFileName := fmt.Sprintf("merged-%d-spec%d-%d.avro", newSnapshotID, se.specID, time.Now().UnixMilli())
|
||||
manifestFileName := fmt.Sprintf("merged-%d-%s-spec%d.avro", newSnapshotID, artifactSuffix, se.specID)
|
||||
manifestPath := path.Join("metadata", manifestFileName)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
@@ -417,11 +442,11 @@ func (h *Handler) rewriteManifests(
|
||||
se.entries,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write merged manifest for spec %d: %w", se.specID, err)
|
||||
return "", nil, fmt.Errorf("write merged manifest for spec %d: %w", se.specID, err)
|
||||
}
|
||||
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestFileName, manifestBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save merged manifest for spec %d: %w", se.specID, err)
|
||||
return "", nil, fmt.Errorf("save merged manifest for spec %d: %w", se.specID, err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestFileName})
|
||||
newManifests = append(newManifests, mergedManifest)
|
||||
@@ -437,13 +462,13 @@ func (h *Handler) rewriteManifests(
|
||||
var manifestListBuf bytes.Buffer
|
||||
err = iceberg.WriteManifestList(version, &manifestListBuf, newSnapshotID, &snapshotID, &newSeqNum, 0, newManifests)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("write manifest list: %w", err)
|
||||
}
|
||||
|
||||
// Save new manifest list
|
||||
manifestListFileName := fmt.Sprintf("snap-%d-%d.avro", newSnapshotID, time.Now().UnixMilli())
|
||||
manifestListFileName := fmt.Sprintf("snap-%d-%s.avro", newSnapshotID, artifactSuffix)
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListFileName, manifestListBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save manifest list: %w", err)
|
||||
return "", nil, fmt.Errorf("save manifest list: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListFileName})
|
||||
|
||||
@@ -484,11 +509,16 @@ func (h *Handler) rewriteManifests(
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit manifest rewrite: %w", err)
|
||||
return "", nil, fmt.Errorf("commit manifest rewrite: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", len(manifests), len(specMap), totalEntries), nil
|
||||
metrics := map[string]int64{
|
||||
MetricManifestsRewritten: int64(len(dataManifests)),
|
||||
MetricEntriesTotal: int64(totalEntries),
|
||||
MetricDurationMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", len(dataManifests), len(specMap), totalEntries), metrics, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
// The following methods export the internal maintenance operations for use
|
||||
// by integration tests. They are intentionally thin wrappers.
|
||||
|
||||
func (h *Handler) ExpireSnapshots(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.expireSnapshots(ctx, client, bucketName, tablePath, config)
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveOrphans(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.removeOrphans(ctx, client, bucketName, tablePath, config)
|
||||
}
|
||||
|
||||
func (h *Handler) RewriteManifests(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.rewriteManifests(ctx, client, bucketName, tablePath, config)
|
||||
}
|
||||
|
||||
func (h *Handler) CompactDataFiles(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string, config Config) (string, map[string]int64, error) {
|
||||
return h.compactDataFiles(ctx, client, bucketName, tablePath, config, nil)
|
||||
}
|
||||
@@ -87,8 +87,44 @@ func (h *VacuumHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only scan this collection when set.",
|
||||
Placeholder: "all collections",
|
||||
Description: "Filter collections for vacuum detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a regex pattern to match specific collections.",
|
||||
Placeholder: "ALL_COLLECTIONS",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "volume_state",
|
||||
Label: "Volume State Filter",
|
||||
Description: "Filter volumes by state: ALL (default), ACTIVE (writable volumes below size limit), or FULL (read-only volumes above size limit).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
|
||||
Options: []*plugin_pb.ConfigOption{
|
||||
{Value: string(volumeStateAll), Label: "All Volumes"},
|
||||
{Value: string(volumeStateActive), Label: "Active (writable)"},
|
||||
{Value: string(volumeStateFull), Label: "Full (read-only)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only vacuum volumes in matching data centers (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "rack_filter",
|
||||
Label: "Rack Filter",
|
||||
Description: "Only vacuum volumes on matching racks (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all racks",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "node_filter",
|
||||
Label: "Node Filter",
|
||||
Description: "Only vacuum volumes on matching nodes (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all nodes",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
@@ -99,6 +135,18 @@ func (h *VacuumHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
"collection_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"volume_state": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: string(volumeStateAll)},
|
||||
},
|
||||
"data_center_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"rack_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"node_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
@@ -226,6 +274,17 @@ func (h *VacuumHandler) Detect(ctx context.Context, request *plugin_pb.RunDetect
|
||||
return err
|
||||
}
|
||||
|
||||
volState := volumeState(strings.ToUpper(strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "volume_state", string(volumeStateAll)))))
|
||||
metrics = filterMetricsByVolumeState(metrics, volState)
|
||||
|
||||
dataCenterFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
rackFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "rack_filter", ""))
|
||||
nodeFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "node_filter", ""))
|
||||
|
||||
if dataCenterFilter != "" || rackFilter != "" || nodeFilter != "" {
|
||||
metrics = filterMetricsByLocation(metrics, dataCenterFilter, rackFilter, nodeFilter)
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
results, err := vacuumtask.Detection(metrics, clusterInfo, workerConfig)
|
||||
if err != nil {
|
||||
@@ -505,7 +564,8 @@ func (h *VacuumHandler) collectVolumeMetrics(
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
return collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
metrics, activeTopology, _, err := collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
return metrics, activeTopology, err
|
||||
}
|
||||
|
||||
func deriveVacuumConfig(values map[string]*plugin_pb.ConfigValue) *vacuumtask.Config {
|
||||
|
||||
@@ -242,6 +242,138 @@ func TestEmitVacuumDetectionDecisionTraceNoTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumDescriptorHasNewFields(t *testing.T) {
|
||||
handler := NewVacuumHandler(nil, 2)
|
||||
desc := handler.Descriptor()
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
adminForm := desc.AdminConfigForm
|
||||
if adminForm == nil {
|
||||
t.Fatal("admin config form is nil")
|
||||
}
|
||||
if len(adminForm.Sections) == 0 {
|
||||
t.Fatal("admin config form has no sections")
|
||||
}
|
||||
|
||||
scopeSection := adminForm.Sections[0]
|
||||
fieldNames := make(map[string]*plugin_pb.ConfigField)
|
||||
for _, f := range scopeSection.Fields {
|
||||
fieldNames[f.Name] = f
|
||||
}
|
||||
|
||||
requiredFields := []string{
|
||||
"collection_filter",
|
||||
"volume_state",
|
||||
"data_center_filter",
|
||||
"rack_filter",
|
||||
"node_filter",
|
||||
}
|
||||
for _, name := range requiredFields {
|
||||
if _, ok := fieldNames[name]; !ok {
|
||||
t.Errorf("missing field %q in admin config scope section", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify volume_state is an enum with correct options.
|
||||
vsField := fieldNames["volume_state"]
|
||||
if vsField.FieldType != plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM {
|
||||
t.Errorf("volume_state field type = %v, want ENUM", vsField.FieldType)
|
||||
}
|
||||
if len(vsField.Options) != 3 {
|
||||
t.Errorf("volume_state options count = %d, want 3", len(vsField.Options))
|
||||
}
|
||||
|
||||
// Verify default values exist for new fields.
|
||||
defaultKeys := []string{"volume_state", "data_center_filter", "rack_filter", "node_filter"}
|
||||
for _, key := range defaultKeys {
|
||||
if _, ok := adminForm.DefaultValues[key]; !ok {
|
||||
t.Errorf("missing default value for %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify volume_state default is ALL.
|
||||
vsDefault := adminForm.DefaultValues["volume_state"]
|
||||
if sv, ok := vsDefault.Kind.(*plugin_pb.ConfigValue_StringValue); !ok || sv.StringValue != "ALL" {
|
||||
t.Errorf("volume_state default = %v, want ALL", vsDefault)
|
||||
}
|
||||
|
||||
// Verify collection_filter description mentions ALL_COLLECTIONS.
|
||||
cfField := fieldNames["collection_filter"]
|
||||
if cfField.Placeholder != "ALL_COLLECTIONS" {
|
||||
t.Errorf("collection_filter placeholder = %q, want ALL_COLLECTIONS", cfField.Placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumFiltersVolumeState(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||
{VolumeID: 2, FullnessRatio: 1.5}, // full
|
||||
{VolumeID: 3, FullnessRatio: 0.9}, // active
|
||||
{VolumeID: 4, FullnessRatio: 1.01}, // full (at threshold)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state volumeState
|
||||
wantIDs []uint32
|
||||
}{
|
||||
{"ALL returns all", volumeStateAll, []uint32{1, 2, 3, 4}},
|
||||
{"ACTIVE returns writable", volumeStateActive, []uint32{1, 3}},
|
||||
{"FULL returns read-only", volumeStateFull, []uint32{2, 4}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := filterMetricsByVolumeState(metrics, tt.state)
|
||||
if len(filtered) != len(tt.wantIDs) {
|
||||
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
|
||||
}
|
||||
for i, m := range filtered {
|
||||
if m.VolumeID != tt.wantIDs[i] {
|
||||
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumFiltersLocation(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, DataCenter: "dc1", Rack: "r1", Server: "s1"},
|
||||
{VolumeID: 2, DataCenter: "dc1", Rack: "r2", Server: "s2"},
|
||||
{VolumeID: 3, DataCenter: "dc2", Rack: "r1", Server: "s3"},
|
||||
{VolumeID: 4, DataCenter: "dc2", Rack: "r3", Server: "s4"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dc string
|
||||
rack string
|
||||
node string
|
||||
wantIDs []uint32
|
||||
}{
|
||||
{"dc filter", "dc1", "", "", []uint32{1, 2}},
|
||||
{"rack filter", "", "r1", "", []uint32{1, 3}},
|
||||
{"node filter", "", "", "s2,s4", []uint32{2, 4}},
|
||||
{"dc + rack", "dc1", "r1", "", []uint32{1}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := filterMetricsByLocation(metrics, tt.dc, tt.rack, tt.node)
|
||||
if len(filtered) != len(tt.wantIDs) {
|
||||
t.Fatalf("got %d metrics, want %d", len(filtered), len(tt.wantIDs))
|
||||
}
|
||||
for i, m := range filtered {
|
||||
if m.VolumeID != tt.wantIDs[i] {
|
||||
t.Errorf("filtered[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.wantIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type noopDetectionSender struct{}
|
||||
|
||||
func (noopDetectionSender) SendProposals(*plugin_pb.DetectionProposals) error { return nil }
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
balancetask "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
@@ -25,6 +26,23 @@ const (
|
||||
maxProposalStringLength = 200
|
||||
)
|
||||
|
||||
// collectionFilterMode controls how collections are handled during balance detection.
|
||||
type collectionFilterMode string
|
||||
|
||||
const (
|
||||
collectionFilterAll collectionFilterMode = "ALL_COLLECTIONS"
|
||||
collectionFilterEach collectionFilterMode = "EACH_COLLECTION"
|
||||
)
|
||||
|
||||
// volumeState controls which volumes participate in balance detection.
|
||||
type volumeState string
|
||||
|
||||
const (
|
||||
volumeStateAll volumeState = "ALL"
|
||||
volumeStateActive volumeState = "ACTIVE"
|
||||
volumeStateFull volumeState = "FULL"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "volume_balance",
|
||||
@@ -85,8 +103,44 @@ func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
{
|
||||
Name: "collection_filter",
|
||||
Label: "Collection Filter",
|
||||
Description: "Only detect balance opportunities in this collection when set.",
|
||||
Placeholder: "all collections",
|
||||
Description: "Filter collections for balance detection. Use ALL_COLLECTIONS (default) to treat all volumes as one pool, EACH_COLLECTION to run detection separately per collection, or a regex pattern to match specific collections.",
|
||||
Placeholder: "ALL_COLLECTIONS",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "volume_state",
|
||||
Label: "Volume State Filter",
|
||||
Description: "Filter volumes by state: ALL (default), ACTIVE (writable volumes below size limit), or FULL (read-only volumes above size limit).",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
|
||||
Options: []*plugin_pb.ConfigOption{
|
||||
{Value: string(volumeStateAll), Label: "All Volumes"},
|
||||
{Value: string(volumeStateActive), Label: "Active (writable)"},
|
||||
{Value: string(volumeStateFull), Label: "Full (read-only)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "data_center_filter",
|
||||
Label: "Data Center Filter",
|
||||
Description: "Only balance volumes in matching data centers (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all data centers",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "rack_filter",
|
||||
Label: "Rack Filter",
|
||||
Description: "Only balance volumes on matching racks (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all racks",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "node_filter",
|
||||
Label: "Node Filter",
|
||||
Description: "Only balance volumes on matching nodes (comma-separated, wildcards supported). Leave empty for all.",
|
||||
Placeholder: "all nodes",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
@@ -97,6 +151,18 @@ func (h *VolumeBalanceHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
"collection_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"volume_state": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: string(volumeStateAll)},
|
||||
},
|
||||
"data_center_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"rack_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
"node_filter": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
@@ -261,16 +327,79 @@ func (h *VolumeBalanceHandler) Detect(
|
||||
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
|
||||
}
|
||||
|
||||
metrics, activeTopology, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
metrics, activeTopology, replicaMap, err := h.collectVolumeMetrics(ctx, masters, collectionFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
|
||||
volState := volumeState(strings.ToUpper(strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "volume_state", string(volumeStateAll)))))
|
||||
metrics = filterMetricsByVolumeState(metrics, volState)
|
||||
|
||||
dataCenterFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "data_center_filter", ""))
|
||||
rackFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "rack_filter", ""))
|
||||
nodeFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "node_filter", ""))
|
||||
|
||||
if dataCenterFilter != "" || rackFilter != "" || nodeFilter != "" {
|
||||
metrics = filterMetricsByLocation(metrics, dataCenterFilter, rackFilter, nodeFilter)
|
||||
}
|
||||
|
||||
workerConfig.TaskConfig.DataCenterFilter = dataCenterFilter
|
||||
workerConfig.TaskConfig.RackFilter = rackFilter
|
||||
workerConfig.TaskConfig.NodeFilter = nodeFilter
|
||||
|
||||
clusterInfo := &workertypes.ClusterInfo{
|
||||
ActiveTopology: activeTopology,
|
||||
VolumeReplicaMap: replicaMap,
|
||||
}
|
||||
maxResults := int(request.MaxResults)
|
||||
results, hasMore, err := balancetask.Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
var results []*workertypes.TaskDetectionResult
|
||||
var hasMore bool
|
||||
|
||||
if collectionFilterMode(collectionFilter) == collectionFilterEach {
|
||||
// Group metrics by collection in a single pass (O(N) instead of O(C*N))
|
||||
metricsByCollection := make(map[string][]*workertypes.VolumeHealthMetrics)
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
metricsByCollection[m.Collection] = append(metricsByCollection[m.Collection], m)
|
||||
}
|
||||
collections := make([]string, 0, len(metricsByCollection))
|
||||
for c := range metricsByCollection {
|
||||
collections = append(collections, c)
|
||||
}
|
||||
sort.Strings(collections)
|
||||
|
||||
budget := maxResults
|
||||
unlimitedBudget := budget <= 0
|
||||
for _, collection := range collections {
|
||||
if !unlimitedBudget && budget <= 0 {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
perCollectionLimit := budget
|
||||
if unlimitedBudget {
|
||||
perCollectionLimit = 0 // Detection treats <= 0 as unbounded
|
||||
}
|
||||
perResults, perHasMore, perErr := balancetask.Detection(metricsByCollection[collection], clusterInfo, workerConfig.TaskConfig, perCollectionLimit)
|
||||
if perErr != nil {
|
||||
return perErr
|
||||
}
|
||||
results = append(results, perResults...)
|
||||
if !unlimitedBudget {
|
||||
budget -= len(perResults)
|
||||
}
|
||||
if perHasMore {
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
results, hasMore, err = balancetask.Detection(metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if traceErr := emitVolumeBalanceDetectionDecisionTrace(sender, metrics, activeTopology, workerConfig.TaskConfig, results); traceErr != nil {
|
||||
@@ -544,6 +673,39 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterMetricsByVolumeState filters volume metrics by state.
|
||||
// "ACTIVE" keeps volumes with FullnessRatio < 1.01 (writable, below size limit).
|
||||
// "FULL" keeps volumes with FullnessRatio >= 1.01 (read-only, above size limit).
|
||||
// "ALL" or any other value returns all metrics unfiltered.
|
||||
func filterMetricsByVolumeState(metrics []*workertypes.VolumeHealthMetrics, state volumeState) []*workertypes.VolumeHealthMetrics {
|
||||
const fullnessThreshold = 1.01
|
||||
|
||||
var predicate func(m *workertypes.VolumeHealthMetrics) bool
|
||||
switch state {
|
||||
case volumeStateActive:
|
||||
predicate = func(m *workertypes.VolumeHealthMetrics) bool {
|
||||
return m.FullnessRatio < fullnessThreshold
|
||||
}
|
||||
case volumeStateFull:
|
||||
predicate = func(m *workertypes.VolumeHealthMetrics) bool {
|
||||
return m.FullnessRatio >= fullnessThreshold
|
||||
}
|
||||
default:
|
||||
return metrics
|
||||
}
|
||||
|
||||
filtered := make([]*workertypes.VolumeHealthMetrics, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if predicate(m) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func countBalanceDiskTypes(metrics []*workertypes.VolumeHealthMetrics) int {
|
||||
diskTypes := make(map[string]struct{})
|
||||
for _, metric := range metrics {
|
||||
@@ -926,10 +1088,8 @@ func (h *VolumeBalanceHandler) collectVolumeMetrics(
|
||||
ctx context.Context,
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
// Reuse the same master topology fetch/build flow used by the vacuum handler.
|
||||
helper := &VacuumHandler{grpcDialOption: h.grpcDialOption}
|
||||
return helper.collectVolumeMetrics(ctx, masterAddresses, collectionFilter)
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
|
||||
return collectVolumeMetricsFromMasters(ctx, masterAddresses, collectionFilter, h.grpcDialOption)
|
||||
}
|
||||
|
||||
func deriveBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *volumeBalanceWorkerConfig {
|
||||
@@ -981,6 +1141,30 @@ func deriveBalanceWorkerConfig(values map[string]*plugin_pb.ConfigValue) *volume
|
||||
}
|
||||
}
|
||||
|
||||
func filterMetricsByLocation(metrics []*workertypes.VolumeHealthMetrics, dcFilter, rackFilter, nodeFilter string) []*workertypes.VolumeHealthMetrics {
|
||||
dcMatchers := wildcard.CompileWildcardMatchers(dcFilter)
|
||||
rackMatchers := wildcard.CompileWildcardMatchers(rackFilter)
|
||||
nodeMatchers := wildcard.CompileWildcardMatchers(nodeFilter)
|
||||
|
||||
filtered := make([]*workertypes.VolumeHealthMetrics, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if !wildcard.MatchesAnyWildcard(dcMatchers, m.DataCenter) {
|
||||
continue
|
||||
}
|
||||
if !wildcard.MatchesAnyWildcard(rackMatchers, m.Rack) {
|
||||
continue
|
||||
}
|
||||
if !wildcard.MatchesAnyWildcard(nodeMatchers, m.Server) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func buildVolumeBalanceProposal(
|
||||
result *workertypes.TaskDetectionResult,
|
||||
) (*plugin_pb.JobProposal, error) {
|
||||
@@ -1207,7 +1391,7 @@ func buildBatchVolumeBalanceProposals(
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"task_type": "balance",
|
||||
"batch": "true",
|
||||
"batch": "true",
|
||||
"batch_size": fmt.Sprintf("%d", len(moves)),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -629,6 +629,157 @@ func TestExecuteSingleMovePathUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByLocation(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, Server: "node-a", DataCenter: "dc1", Rack: "rack1"},
|
||||
{VolumeID: 2, Server: "node-b", DataCenter: "dc1", Rack: "rack2"},
|
||||
{VolumeID: 3, Server: "node-c", DataCenter: "dc2", Rack: "rack1"},
|
||||
{VolumeID: 4, Server: "node-d", DataCenter: "dc2", Rack: "rack3"},
|
||||
}
|
||||
|
||||
// Filter by DC
|
||||
filtered := filterMetricsByLocation(metrics, "dc1", "", "")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("DC filter: expected 2, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Filter by rack
|
||||
filtered = filterMetricsByLocation(metrics, "", "rack1,rack2", "")
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("rack filter: expected 3, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Filter by node
|
||||
filtered = filterMetricsByLocation(metrics, "", "", "node-a,node-c")
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("node filter: expected 2, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Combined DC + rack
|
||||
filtered = filterMetricsByLocation(metrics, "dc2", "rack3", "")
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("DC+rack filter: expected 1, got %d", len(filtered))
|
||||
}
|
||||
|
||||
// Empty filters pass all
|
||||
filtered = filterMetricsByLocation(metrics, "", "", "")
|
||||
if len(filtered) != 4 {
|
||||
t.Fatalf("no filter: expected 4, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
{VolumeID: 1, FullnessRatio: 0.5}, // active
|
||||
{VolumeID: 2, FullnessRatio: 1.0}, // active (below 1.01)
|
||||
{VolumeID: 3, FullnessRatio: 1.009}, // active (below 1.01)
|
||||
{VolumeID: 4, FullnessRatio: 1.01}, // full (exactly at threshold)
|
||||
{VolumeID: 5, FullnessRatio: 1.5}, // full
|
||||
{VolumeID: 6, FullnessRatio: 2.0}, // full
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
expectedIDs []uint32
|
||||
}{
|
||||
{
|
||||
name: "ALL returns everything",
|
||||
state: "ALL",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "empty string returns everything",
|
||||
state: "",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "ACTIVE keeps FullnessRatio below 1.01",
|
||||
state: "ACTIVE",
|
||||
expectedIDs: []uint32{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "FULL keeps FullnessRatio at or above 1.01",
|
||||
state: "FULL",
|
||||
expectedIDs: []uint32{4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "unknown value returns everything",
|
||||
state: "INVALID",
|
||||
expectedIDs: []uint32{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := filterMetricsByVolumeState(metrics, volumeState(tt.state))
|
||||
if len(result) != len(tt.expectedIDs) {
|
||||
t.Fatalf("expected %d metrics, got %d", len(tt.expectedIDs), len(result))
|
||||
}
|
||||
for i, m := range result {
|
||||
if m.VolumeID != tt.expectedIDs[i] {
|
||||
t.Errorf("result[%d].VolumeID = %d, want %d", i, m.VolumeID, tt.expectedIDs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState_NilElement(t *testing.T) {
|
||||
metrics := []*workertypes.VolumeHealthMetrics{
|
||||
nil,
|
||||
{VolumeID: 1, FullnessRatio: 0.5},
|
||||
nil,
|
||||
{VolumeID: 2, FullnessRatio: 1.5},
|
||||
}
|
||||
result := filterMetricsByVolumeState(metrics, volumeStateActive)
|
||||
if len(result) != 1 || result[0].VolumeID != 1 {
|
||||
t.Fatalf("expected [vol 1] for ACTIVE with nil elements, got %d results", len(result))
|
||||
}
|
||||
result = filterMetricsByVolumeState(metrics, volumeStateFull)
|
||||
if len(result) != 1 || result[0].VolumeID != 2 {
|
||||
t.Fatalf("expected [vol 2] for FULL with nil elements, got %d results", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMetricsByVolumeState_EmptyInput(t *testing.T) {
|
||||
result := filterMetricsByVolumeState(nil, volumeStateActive)
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 metrics for nil input, got %d", len(result))
|
||||
}
|
||||
|
||||
result = filterMetricsByVolumeState([]*workertypes.VolumeHealthMetrics{}, volumeStateFull)
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 metrics for empty input, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeBalanceDescriptorHasVolumeStateField(t *testing.T) {
|
||||
descriptor := NewVolumeBalanceHandler(nil).Descriptor()
|
||||
if descriptor == nil || descriptor.AdminConfigForm == nil {
|
||||
t.Fatalf("expected admin config form in descriptor")
|
||||
}
|
||||
found := false
|
||||
for _, section := range descriptor.AdminConfigForm.Sections {
|
||||
for _, field := range section.Fields {
|
||||
if field.Name == "volume_state" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected volume_state field in admin config form")
|
||||
}
|
||||
defaultVal, ok := descriptor.AdminConfigForm.DefaultValues["volume_state"]
|
||||
if !ok {
|
||||
t.Fatalf("expected volume_state default value")
|
||||
}
|
||||
if defaultVal.GetStringValue() != "ALL" {
|
||||
t.Fatalf("expected volume_state default 'ALL', got %q", defaultVal.GetStringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func workerConfigFormHasField(form *plugin_pb.ConfigForm, fieldName string) bool {
|
||||
if form == nil {
|
||||
return false
|
||||
|
||||
@@ -2,7 +2,9 @@ package pluginworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,7 +13,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
workertypes "github.com/seaweedfs/seaweedfs/weed/worker/types"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
@@ -21,12 +22,12 @@ func collectVolumeMetricsFromMasters(
|
||||
masterAddresses []string,
|
||||
collectionFilter string,
|
||||
grpcDialOption grpc.DialOption,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
|
||||
if grpcDialOption == nil {
|
||||
return nil, nil, fmt.Errorf("grpc dial option is not configured")
|
||||
return nil, nil, nil, fmt.Errorf("grpc dial option is not configured")
|
||||
}
|
||||
if len(masterAddresses) == 0 {
|
||||
return nil, nil, fmt.Errorf("no master addresses provided in cluster context")
|
||||
return nil, nil, nil, fmt.Errorf("no master addresses provided in cluster context")
|
||||
}
|
||||
|
||||
for _, masterAddress := range masterAddresses {
|
||||
@@ -36,15 +37,20 @@ func collectVolumeMetricsFromMasters(
|
||||
continue
|
||||
}
|
||||
|
||||
metrics, activeTopology, buildErr := buildVolumeMetrics(response, collectionFilter)
|
||||
metrics, activeTopology, replicaMap, buildErr := buildVolumeMetrics(response, collectionFilter)
|
||||
if buildErr != nil {
|
||||
// Configuration errors (e.g. invalid regex) will fail on every master,
|
||||
// so return immediately instead of masking them with retries.
|
||||
if isConfigError(buildErr) {
|
||||
return nil, nil, nil, buildErr
|
||||
}
|
||||
glog.Warningf("Plugin worker failed to build metrics from master %s: %v", masterAddress, buildErr)
|
||||
continue
|
||||
}
|
||||
return metrics, activeTopology, nil
|
||||
return metrics, activeTopology, replicaMap, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to load topology from all provided masters")
|
||||
return nil, nil, nil, fmt.Errorf("failed to load topology from all provided masters")
|
||||
}
|
||||
|
||||
func fetchVolumeList(ctx context.Context, address string, grpcDialOption grpc.DialOption) (*master_pb.VolumeListResponse, error) {
|
||||
@@ -83,27 +89,46 @@ func fetchVolumeList(ctx context.Context, address string, grpcDialOption grpc.Di
|
||||
func buildVolumeMetrics(
|
||||
response *master_pb.VolumeListResponse,
|
||||
collectionFilter string,
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, error) {
|
||||
) ([]*workertypes.VolumeHealthMetrics, *topology.ActiveTopology, map[uint32][]workertypes.ReplicaLocation, error) {
|
||||
if response == nil || response.TopologyInfo == nil {
|
||||
return nil, nil, fmt.Errorf("volume list response has no topology info")
|
||||
return nil, nil, nil, fmt.Errorf("volume list response has no topology info")
|
||||
}
|
||||
|
||||
activeTopology := topology.NewActiveTopology(10)
|
||||
if err := activeTopology.UpdateTopology(response.TopologyInfo); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var collectionRegex *regexp.Regexp
|
||||
trimmedFilter := strings.TrimSpace(collectionFilter)
|
||||
filterMode := collectionFilterMode(trimmedFilter)
|
||||
if trimmedFilter != "" && filterMode != collectionFilterAll && filterMode != collectionFilterEach && trimmedFilter != "*" {
|
||||
var err error
|
||||
collectionRegex, err = regexp.Compile(trimmedFilter)
|
||||
if err != nil {
|
||||
return nil, nil, nil, &configError{err: fmt.Errorf("invalid collection_filter regex %q: %w", trimmedFilter, err)}
|
||||
}
|
||||
}
|
||||
|
||||
patterns := wildcard.CompileWildcardMatchers(collectionFilter)
|
||||
volumeSizeLimitBytes := uint64(response.VolumeSizeLimitMb) * 1024 * 1024
|
||||
now := time.Now()
|
||||
metrics := make([]*workertypes.VolumeHealthMetrics, 0, 256)
|
||||
replicaMap := make(map[uint32][]workertypes.ReplicaLocation)
|
||||
|
||||
for _, dc := range response.TopologyInfo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
for _, node := range rack.DataNodeInfos {
|
||||
for diskType, diskInfo := range node.DiskInfos {
|
||||
for _, volume := range diskInfo.VolumeInfos {
|
||||
if !wildcard.MatchesAnyWildcard(patterns, volume.Collection) {
|
||||
// Build replica map from ALL volumes BEFORE collection filtering,
|
||||
// since replicas may span filtered/unfiltered nodes.
|
||||
replicaMap[volume.Id] = append(replicaMap[volume.Id], workertypes.ReplicaLocation{
|
||||
DataCenter: dc.Id,
|
||||
Rack: rack.Id,
|
||||
NodeID: node.Id,
|
||||
})
|
||||
|
||||
if collectionRegex != nil && !collectionRegex.MatchString(volume.Collection) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -145,7 +170,20 @@ func buildVolumeMetrics(
|
||||
metric.ReplicaCount = replicaCounts[metric.VolumeID]
|
||||
}
|
||||
|
||||
return metrics, activeTopology, nil
|
||||
return metrics, activeTopology, replicaMap, nil
|
||||
}
|
||||
|
||||
// configError wraps configuration errors that should not be retried across masters.
|
||||
type configError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *configError) Error() string { return e.err.Error() }
|
||||
func (e *configError) Unwrap() error { return e.err }
|
||||
|
||||
func isConfigError(err error) bool {
|
||||
var ce *configError
|
||||
return errors.As(err, &ce)
|
||||
}
|
||||
|
||||
func masterAddressCandidates(address string) []string {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
func makeTestVolumeListResponse(volumes ...*master_pb.VolumeInformationMessage) *master_pb.VolumeListResponse {
|
||||
return &master_pb.VolumeListResponse{
|
||||
VolumeSizeLimitMb: 30000,
|
||||
TopologyInfo: &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "server1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
VolumeInfos: volumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsEmptyFilter(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
)
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected 2 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsAllCollections(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
)
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, string(collectionFilterAll))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected 2 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsEachCollection(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
)
|
||||
// EACH_COLLECTION passes all volumes through; filtering happens in the handler
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, string(collectionFilterEach))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected 2 metrics, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsRegexFilter(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
&master_pb.VolumeInformationMessage{Id: 2, Collection: "videos", Size: 200},
|
||||
&master_pb.VolumeInformationMessage{Id: 3, Collection: "photos-backup", Size: 300},
|
||||
)
|
||||
metrics, _, _, err := buildVolumeMetrics(resp, "^photos$")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
if metrics[0].Collection != "photos" {
|
||||
t.Fatalf("expected collection 'photos', got %q", metrics[0].Collection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVolumeMetricsInvalidRegex(t *testing.T) {
|
||||
resp := makeTestVolumeListResponse(
|
||||
&master_pb.VolumeInformationMessage{Id: 1, Collection: "photos", Size: 100},
|
||||
)
|
||||
_, _, _, err := buildVolumeMetrics(resp, "[invalid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid regex")
|
||||
}
|
||||
if !isConfigError(err) {
|
||||
t.Fatalf("expected config error for invalid regex, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,68 @@ type azureRemoteStorageClient struct {
|
||||
|
||||
var _ = remote_storage.RemoteStorageClient(&azureRemoteStorageClient{})
|
||||
|
||||
func (az *azureRemoteStorageClient) ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) (err error) {
|
||||
pathKey := loc.Path[1:]
|
||||
if pathKey != "" && !strings.HasSuffix(pathKey, "/") {
|
||||
pathKey += "/"
|
||||
}
|
||||
|
||||
containerClient := az.client.ServiceClient().NewContainerClient(loc.Bucket)
|
||||
pager := containerClient.NewListBlobsHierarchyPager("/", &container.ListBlobsHierarchyOptions{
|
||||
Prefix: &pathKey,
|
||||
})
|
||||
|
||||
for pager.More() {
|
||||
resp, pageErr := pager.NextPage(ctx)
|
||||
if pageErr != nil {
|
||||
return fmt.Errorf("azure list directory %s%s: %w", loc.Bucket, loc.Path, pageErr)
|
||||
}
|
||||
|
||||
for _, prefix := range resp.Segment.BlobPrefixes {
|
||||
if prefix.Name == nil {
|
||||
continue
|
||||
}
|
||||
dirKey := "/" + strings.TrimSuffix(*prefix.Name, "/")
|
||||
dir, name := util.FullPath(dirKey).DirAndName()
|
||||
if err = visitFn(dir, name, true, nil); err != nil {
|
||||
return fmt.Errorf("azure processing directory prefix %s: %w", *prefix.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, blobItem := range resp.Segment.BlobItems {
|
||||
if blobItem.Name == nil {
|
||||
continue
|
||||
}
|
||||
key := "/" + *blobItem.Name
|
||||
if strings.HasSuffix(key, "/") {
|
||||
continue // skip directory markers
|
||||
}
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
|
||||
remoteEntry := &filer_pb.RemoteEntry{
|
||||
StorageName: az.conf.Name,
|
||||
}
|
||||
if blobItem.Properties != nil {
|
||||
if blobItem.Properties.LastModified != nil {
|
||||
remoteEntry.RemoteMtime = blobItem.Properties.LastModified.Unix()
|
||||
}
|
||||
if blobItem.Properties.ContentLength != nil {
|
||||
remoteEntry.RemoteSize = *blobItem.Properties.ContentLength
|
||||
}
|
||||
if blobItem.Properties.ETag != nil {
|
||||
remoteEntry.RemoteETag = string(*blobItem.Properties.ETag)
|
||||
}
|
||||
}
|
||||
|
||||
if err = visitFn(dir, name, false, remoteEntry); err != nil {
|
||||
return fmt.Errorf("azure processing blob %s: %w", *blobItem.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (az *azureRemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
key := loc.Path[1:]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), DefaultAzureOpTimeout)
|
||||
|
||||
@@ -131,6 +131,52 @@ func (gcs *gcsRemoteStorageClient) Traverse(loc *remote_pb.RemoteStorageLocation
|
||||
|
||||
const defaultGCSOpTimeout = 30 * time.Second
|
||||
|
||||
func (gcs *gcsRemoteStorageClient) ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) (err error) {
|
||||
pathKey := loc.Path[1:]
|
||||
if pathKey != "" && !strings.HasSuffix(pathKey, "/") {
|
||||
pathKey += "/"
|
||||
}
|
||||
|
||||
objectIterator := gcs.client.Bucket(loc.Bucket).Objects(ctx, &storage.Query{
|
||||
Delimiter: "/",
|
||||
Prefix: pathKey,
|
||||
Versions: false,
|
||||
})
|
||||
|
||||
for {
|
||||
objectAttr, iterErr := objectIterator.Next()
|
||||
if iterErr != nil {
|
||||
if iterErr == iterator.Done {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("list directory %s%s: %w", loc.Bucket, loc.Path, iterErr)
|
||||
}
|
||||
|
||||
if objectAttr.Prefix != "" {
|
||||
// Common prefix → subdirectory
|
||||
dirKey := "/" + strings.TrimSuffix(objectAttr.Prefix, "/")
|
||||
dir, name := util.FullPath(dirKey).DirAndName()
|
||||
if err = visitFn(dir, name, true, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
key := "/" + objectAttr.Name
|
||||
if strings.HasSuffix(key, "/") {
|
||||
continue // skip directory markers
|
||||
}
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
if err = visitFn(dir, name, false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: objectAttr.Updated.Unix(),
|
||||
RemoteSize: objectAttr.Size,
|
||||
RemoteETag: objectAttr.Etag,
|
||||
StorageName: gcs.conf.Name,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gcs *gcsRemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
key := loc.Path[1:]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultGCSOpTimeout)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package remote_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -75,6 +76,7 @@ var ErrRemoteObjectNotFound = errors.New("remote object not found")
|
||||
|
||||
type RemoteStorageClient interface {
|
||||
Traverse(loc *remote_pb.RemoteStorageLocation, visitFn VisitFunc) error
|
||||
ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn VisitFunc) error
|
||||
StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error)
|
||||
ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (data []byte, err error)
|
||||
WriteDirectory(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry) (err error)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
@@ -98,12 +100,19 @@ func (s *s3RemoteStorageClient) Traverse(remote *remote_pb.RemoteStorageLocation
|
||||
key := *content.Key
|
||||
key = "/" + key
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
if err := visitFn(dir, name, false, &filer_pb.RemoteEntry{
|
||||
RemoteMtime: (*content.LastModified).Unix(),
|
||||
RemoteSize: *content.Size,
|
||||
RemoteETag: *content.ETag,
|
||||
remoteEntry := &filer_pb.RemoteEntry{
|
||||
StorageName: s.conf.Name,
|
||||
}); err != nil {
|
||||
}
|
||||
if content.LastModified != nil {
|
||||
remoteEntry.RemoteMtime = content.LastModified.Unix()
|
||||
}
|
||||
if content.Size != nil {
|
||||
remoteEntry.RemoteSize = *content.Size
|
||||
}
|
||||
if content.ETag != nil {
|
||||
remoteEntry.RemoteETag = *content.ETag
|
||||
}
|
||||
if err := visitFn(dir, name, false, remoteEntry); err != nil {
|
||||
localErr = err
|
||||
return false
|
||||
}
|
||||
@@ -122,6 +131,65 @@ func (s *s3RemoteStorageClient) Traverse(remote *remote_pb.RemoteStorageLocation
|
||||
return
|
||||
}
|
||||
|
||||
func (s *s3RemoteStorageClient) ListDirectory(ctx context.Context, loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
|
||||
pathKey := loc.Path[1:]
|
||||
if pathKey != "" && !strings.HasSuffix(pathKey, "/") {
|
||||
pathKey += "/"
|
||||
}
|
||||
|
||||
listInput := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Prefix: aws.String(pathKey),
|
||||
Delimiter: aws.String("/"),
|
||||
}
|
||||
|
||||
var localErr error
|
||||
listErr := s.conn.ListObjectsV2PagesWithContext(ctx, listInput, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
|
||||
for _, prefix := range page.CommonPrefixes {
|
||||
if prefix.Prefix == nil {
|
||||
continue
|
||||
}
|
||||
dirKey := "/" + strings.TrimSuffix(*prefix.Prefix, "/")
|
||||
dir, name := util.FullPath(dirKey).DirAndName()
|
||||
if err := visitFn(dir, name, true, nil); err != nil {
|
||||
localErr = err
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, content := range page.Contents {
|
||||
key := "/" + *content.Key
|
||||
if strings.HasSuffix(key, "/") {
|
||||
continue // skip directory markers
|
||||
}
|
||||
dir, name := util.FullPath(key).DirAndName()
|
||||
remoteEntry := &filer_pb.RemoteEntry{
|
||||
StorageName: s.conf.Name,
|
||||
}
|
||||
if content.LastModified != nil {
|
||||
remoteEntry.RemoteMtime = content.LastModified.Unix()
|
||||
}
|
||||
if content.Size != nil {
|
||||
remoteEntry.RemoteSize = *content.Size
|
||||
}
|
||||
if content.ETag != nil {
|
||||
remoteEntry.RemoteETag = *content.ETag
|
||||
}
|
||||
if err := visitFn(dir, name, false, remoteEntry); err != nil {
|
||||
localErr = err
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if listErr != nil {
|
||||
return fmt.Errorf("list directory %v: %w", loc, listErr)
|
||||
}
|
||||
if localErr != nil {
|
||||
return fmt.Errorf("process directory %v: %w", loc, localErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3RemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
|
||||
resp, err := s.conn.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
@@ -207,13 +275,16 @@ func (s *s3RemoteStorageClient) WriteFile(loc *remote_pb.RemoteStorageLocation,
|
||||
}
|
||||
|
||||
// Upload the file to S3.
|
||||
_, err = uploader.Upload(&s3manager.UploadInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Key: aws.String(loc.Path[1:]),
|
||||
Body: reader,
|
||||
Tagging: awsTags,
|
||||
StorageClass: aws.String(s.conf.S3StorageClass),
|
||||
})
|
||||
uploadInput := &s3manager.UploadInput{
|
||||
Bucket: aws.String(loc.Bucket),
|
||||
Key: aws.String(loc.Path[1:]),
|
||||
Body: reader,
|
||||
Tagging: awsTags,
|
||||
}
|
||||
if s.conf.S3StorageClass != "" {
|
||||
uploadInput.StorageClass = aws.String(s.conf.S3StorageClass)
|
||||
}
|
||||
_, err = uploader.Upload(uploadInput)
|
||||
|
||||
//in case it fails to upload
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -241,17 +242,44 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
|
||||
}
|
||||
|
||||
eofBackoff := time.Duration(0)
|
||||
var partialData []byte
|
||||
var savedFilename string
|
||||
var savedHeader http.Header
|
||||
var savedSourceUrl string
|
||||
retryName := fmt.Sprintf("replicate chunk %s", sourceChunk.GetFileIdString())
|
||||
err = util.RetryUntil(retryName, func() error {
|
||||
filename, header, resp, readErr := fs.filerSource.ReadPart(sourceChunk.GetFileIdString())
|
||||
filename, header, resp, readErr := fs.filerSource.ReadPart(sourceChunk.GetFileIdString(), int64(len(partialData)))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read part %s: %w", sourceChunk.GetFileIdString(), readErr)
|
||||
}
|
||||
defer util_http.CloseResponse(resp)
|
||||
|
||||
sourceUrl := ""
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
sourceUrl = resp.Request.URL.String()
|
||||
// Save metadata from first successful response
|
||||
if len(partialData) == 0 {
|
||||
savedFilename = filename
|
||||
savedHeader = header
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
savedSourceUrl = resp.Request.URL.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Read the response body
|
||||
data, readBodyErr := io.ReadAll(resp.Body)
|
||||
if readBodyErr != nil {
|
||||
// Keep whatever bytes we received before the error
|
||||
partialData = append(partialData, data...)
|
||||
return fmt.Errorf("read body: %w", readBodyErr)
|
||||
}
|
||||
|
||||
// Combine with previously accumulated partial data
|
||||
var fullData []byte
|
||||
if len(partialData) > 0 {
|
||||
fullData = append(partialData, data...)
|
||||
glog.V(0).Infof("resumed reading %s, got %d + %d = %d bytes",
|
||||
sourceChunk.GetFileIdString(), len(partialData), len(data), len(fullData))
|
||||
partialData = nil
|
||||
} else {
|
||||
fullData = data
|
||||
}
|
||||
|
||||
currentFileId, uploadResult, uploadErr, _ := uploader.UploadWithRetry(
|
||||
@@ -266,20 +294,20 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
|
||||
Path: path,
|
||||
},
|
||||
&operation.UploadOption{
|
||||
Filename: filename,
|
||||
Filename: savedFilename,
|
||||
Cipher: false,
|
||||
IsInputCompressed: "gzip" == header.Get("Content-Encoding"),
|
||||
MimeType: header.Get("Content-Type"),
|
||||
IsInputCompressed: "gzip" == savedHeader.Get("Content-Encoding"),
|
||||
MimeType: savedHeader.Get("Content-Type"),
|
||||
PairMap: nil,
|
||||
RetryForever: false,
|
||||
SourceUrl: sourceUrl,
|
||||
SourceUrl: savedSourceUrl,
|
||||
},
|
||||
func(host, fileId string) string {
|
||||
fileUrl := fs.buildUploadUrl(host, fileId)
|
||||
glog.V(4).Infof("replicating %s to %s header:%+v", filename, fileUrl, header)
|
||||
glog.V(4).Infof("replicating %s to %s header:%+v", savedFilename, fileUrl, savedHeader)
|
||||
return fileUrl
|
||||
},
|
||||
resp.Body,
|
||||
util.NewBytesReader(fullData),
|
||||
)
|
||||
if uploadErr != nil {
|
||||
return fmt.Errorf("upload data: %w", uploadErr)
|
||||
@@ -291,17 +319,18 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
|
||||
eofBackoff = 0
|
||||
fileId = currentFileId
|
||||
return nil
|
||||
}, func(uploadErr error) (shouldContinue bool) {
|
||||
}, func(retryErr error) (shouldContinue bool) {
|
||||
if fs.hasSourceNewerVersion(path, sourceMtime) {
|
||||
glog.V(1).Infof("skip retrying stale source %s for %s: %v", sourceChunk.GetFileIdString(), path, uploadErr)
|
||||
glog.V(1).Infof("skip retrying stale source %s for %s: %v", sourceChunk.GetFileIdString(), path, retryErr)
|
||||
return false
|
||||
}
|
||||
if isEofError(uploadErr) {
|
||||
if isEofError(retryErr) {
|
||||
eofBackoff = nextEofBackoff(eofBackoff)
|
||||
glog.V(0).Infof("source connection interrupted while replicating %s for %s, backing off %v: %v", sourceChunk.GetFileIdString(), path, eofBackoff, uploadErr)
|
||||
glog.V(0).Infof("source connection interrupted while replicating %s for %s (%d bytes received so far), backing off %v: %v",
|
||||
sourceChunk.GetFileIdString(), path, len(partialData), eofBackoff, retryErr)
|
||||
time.Sleep(eofBackoff)
|
||||
} else {
|
||||
glog.V(0).Infof("replicate %s for %s: %v", sourceChunk.GetFileIdString(), path, uploadErr)
|
||||
glog.V(0).Infof("replicate %s for %s: %v", sourceChunk.GetFileIdString(), path, retryErr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ package source
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -18,10 +17,6 @@ import (
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
type ReplicationSource interface {
|
||||
ReadPart(part string) io.ReadCloser
|
||||
}
|
||||
|
||||
type FilerSource struct {
|
||||
grpcAddress string
|
||||
grpcDialOption grpc.DialOption
|
||||
@@ -104,14 +99,14 @@ func (fs *FilerSource) LookupFileId(ctx context.Context, part string) (fileUrls
|
||||
return
|
||||
}
|
||||
|
||||
func (fs *FilerSource) ReadPart(fileId string) (filename string, header http.Header, resp *http.Response, err error) {
|
||||
func (fs *FilerSource) ReadPart(fileId string, offset int64) (filename string, header http.Header, resp *http.Response, err error) {
|
||||
|
||||
if fs.proxyByFiler {
|
||||
filename, header, resp, err = util_http.DownloadFile("http://"+fs.address+"/?proxyChunkId="+fileId, "")
|
||||
filename, header, resp, err = util_http.DownloadFile("http://"+fs.address+"/?proxyChunkId="+fileId, "", offset)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("read part %s via filer proxy %s: %v", fileId, fs.address, err)
|
||||
glog.V(0).Infof("read part %s via filer proxy %s offset %d: %v", fileId, fs.address, offset, err)
|
||||
} else {
|
||||
glog.V(4).Infof("read part %s via filer proxy %s content-length:%s", fileId, fs.address, header.Get("Content-Length"))
|
||||
glog.V(4).Infof("read part %s via filer proxy %s offset %d content-length:%s", fileId, fs.address, offset, header.Get("Content-Length"))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -122,11 +117,11 @@ func (fs *FilerSource) ReadPart(fileId string) (filename string, header http.Hea
|
||||
}
|
||||
|
||||
for _, fileUrl := range fileUrls {
|
||||
filename, header, resp, err = util_http.DownloadFile(fileUrl, "")
|
||||
filename, header, resp, err = util_http.DownloadFile(fileUrl, "", offset)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("fail to read part %s from %s: %v", fileId, fileUrl, err)
|
||||
glog.V(0).Infof("fail to read part %s from %s offset %d: %v", fileId, fileUrl, offset, err)
|
||||
} else {
|
||||
glog.V(4).Infof("read part %s from %s content-length:%s", fileId, fileUrl, header.Get("Content-Length"))
|
||||
glog.V(4).Infof("read part %s from %s offset %d content-length:%s", fileId, fileUrl, offset, header.Get("Content-Length"))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
util_http.InitGlobalHttpClient()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestDownloadFile_NoOffset(t *testing.T) {
|
||||
testData := []byte("0123456789abcdefghij")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "" {
|
||||
t.Error("Range header should not be set when offset is 0")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write(testData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, _, resp, err := util_http.DownloadFile(server.URL, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, testData) {
|
||||
t.Fatalf("expected %q, got %q", testData, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile_WithOffset(t *testing.T) {
|
||||
testData := []byte("0123456789abcdefghij")
|
||||
|
||||
var receivedRange string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedRange = r.Header.Get("Range")
|
||||
var offset int
|
||||
fmt.Sscanf(receivedRange, "bytes=%d-", &offset)
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, len(testData)-1, len(testData)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
w.Write(testData[offset:])
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, _, resp, err := util_http.DownloadFile(server.URL, "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if receivedRange != "bytes=10-" {
|
||||
t.Fatalf("expected Range header %q, got %q", "bytes=10-", receivedRange)
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("expected status 206, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, testData[10:]) {
|
||||
t.Fatalf("expected %q, got %q", testData[10:], data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile_RejectsIgnoredRange(t *testing.T) {
|
||||
// Server ignores Range header and returns 200 OK with full body
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("full body"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, _, _, err := util_http.DownloadFile(server.URL, "", 100)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server ignores Range and returns 200")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile_ContentDisposition(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="test.txt"`)
|
||||
w.Write([]byte("data"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename, _, resp, err := util_http.DownloadFile(server.URL, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if filename != "test.txt" {
|
||||
t.Fatalf("expected filename %q, got %q", "test.txt", filename)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadFile_PartialReadThenResume simulates a connection drop
|
||||
// after partial data, then resumes from the offset. Verifies the combined
|
||||
// data matches the original.
|
||||
func TestDownloadFile_PartialReadThenResume(t *testing.T) {
|
||||
testData := bytes.Repeat([]byte("abcdefghij"), 100) // 1000 bytes
|
||||
dropAfter := 500
|
||||
|
||||
var requestNum atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := requestNum.Add(1)
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
|
||||
if n == 1 && rangeHeader == "" {
|
||||
// First request: write partial data then kill the connection
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Error("server doesn't support hijacking")
|
||||
return
|
||||
}
|
||||
conn, buf, _ := hj.Hijack()
|
||||
fmt.Fprintf(buf, "HTTP/1.1 200 OK\r\n")
|
||||
fmt.Fprintf(buf, "Content-Length: %d\r\n", len(testData))
|
||||
fmt.Fprintf(buf, "Content-Type: application/octet-stream\r\n")
|
||||
fmt.Fprintf(buf, "\r\n")
|
||||
buf.Write(testData[:dropAfter])
|
||||
buf.Flush()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Resume request with Range
|
||||
var offset int
|
||||
fmt.Sscanf(rangeHeader, "bytes=%d-", &offset)
|
||||
w.Header().Set("Content-Range",
|
||||
fmt.Sprintf("bytes %d-%d/%d", offset, len(testData)-1, len(testData)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
w.Write(testData[offset:])
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// First read — should get partial data + error
|
||||
_, _, resp1, err := util_http.DownloadFile(server.URL, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partialData, readErr := io.ReadAll(resp1.Body)
|
||||
resp1.Body.Close()
|
||||
if readErr == nil {
|
||||
t.Fatal("expected error from truncated response")
|
||||
}
|
||||
if len(partialData) == 0 {
|
||||
t.Fatal("expected some partial data")
|
||||
}
|
||||
if !bytes.Equal(partialData, testData[:len(partialData)]) {
|
||||
t.Fatal("partial data doesn't match beginning of original")
|
||||
}
|
||||
|
||||
// Resume from where we left off
|
||||
_, _, resp2, err := util_http.DownloadFile(server.URL, "", int64(len(partialData)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
remainingData, readErr := io.ReadAll(resp2.Body)
|
||||
if readErr != nil {
|
||||
t.Fatalf("unexpected error on resume: %v", readErr)
|
||||
}
|
||||
|
||||
// Combined data must match original
|
||||
fullData := append(partialData, remainingData...)
|
||||
if !bytes.Equal(fullData, testData) {
|
||||
t.Fatalf("combined data mismatch: got %d bytes, want %d", len(fullData), len(testData))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadFile_GzipPartialReadThenResume verifies the tricky case
|
||||
// where the first response is gzip-encoded (and Go's HTTP client auto-
|
||||
// decompresses it), but the resume request gets uncompressed data (because
|
||||
// Go doesn't add Accept-Encoding when Range is set). The combined
|
||||
// decompressed bytes must still match the original.
|
||||
func TestDownloadFile_GzipPartialReadThenResume(t *testing.T) {
|
||||
testData := bytes.Repeat([]byte("hello world gzip test "), 100) // ~2200 bytes
|
||||
|
||||
// Pre-compress
|
||||
var compressed bytes.Buffer
|
||||
gz := gzip.NewWriter(&compressed)
|
||||
gz.Write(testData)
|
||||
gz.Close()
|
||||
compressedData := compressed.Bytes()
|
||||
dropAfter := len(compressedData) / 2
|
||||
|
||||
var requestNum atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := requestNum.Add(1)
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
acceptsGzip := r.Header.Get("Accept-Encoding") != ""
|
||||
|
||||
if n == 1 && acceptsGzip && rangeHeader == "" {
|
||||
// First request: serve gzip-encoded response, drop mid-stream.
|
||||
// Go's HTTP client auto-added Accept-Encoding: gzip (no Range),
|
||||
// so it will auto-decompress and strip the Content-Encoding header.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Error("server doesn't support hijacking")
|
||||
return
|
||||
}
|
||||
conn, buf, _ := hj.Hijack()
|
||||
fmt.Fprintf(buf, "HTTP/1.1 200 OK\r\n")
|
||||
fmt.Fprintf(buf, "Content-Encoding: gzip\r\n")
|
||||
fmt.Fprintf(buf, "Content-Length: %d\r\n", len(compressedData))
|
||||
fmt.Fprintf(buf, "Content-Type: application/octet-stream\r\n")
|
||||
fmt.Fprintf(buf, "\r\n")
|
||||
buf.Write(compressedData[:dropAfter])
|
||||
buf.Flush()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Resume request: Range is set, Go did NOT add Accept-Encoding,
|
||||
// so we serve decompressed data from the requested offset —
|
||||
// mimicking what the volume server does when the client doesn't
|
||||
// advertise gzip support.
|
||||
var offset int
|
||||
fmt.Sscanf(rangeHeader, "bytes=%d-", &offset)
|
||||
remaining := testData[offset:]
|
||||
w.Header().Set("Content-Range",
|
||||
fmt.Sprintf("bytes %d-%d/%d", offset, len(testData)-1, len(testData)))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
w.Write(remaining)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// First read: Go auto-decompresses; truncated stream → error + partial data
|
||||
_, _, resp1, err := util_http.DownloadFile(server.URL, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partialData, readErr := io.ReadAll(resp1.Body)
|
||||
resp1.Body.Close()
|
||||
if readErr == nil {
|
||||
t.Fatal("expected error from truncated gzip response")
|
||||
}
|
||||
if len(partialData) == 0 {
|
||||
t.Fatal("expected some decompressed partial data")
|
||||
}
|
||||
// Partial decompressed data must match the beginning of the original
|
||||
if !bytes.Equal(partialData, testData[:len(partialData)]) {
|
||||
t.Fatalf("partial decompressed data doesn't match original (got %d bytes)", len(partialData))
|
||||
}
|
||||
|
||||
// Resume: offset is in the *decompressed* domain.
|
||||
// The server (like the volume server) decompresses and serves from that offset.
|
||||
_, _, resp2, err := util_http.DownloadFile(server.URL, "", int64(len(partialData)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
remainingData, readErr := io.ReadAll(resp2.Body)
|
||||
if readErr != nil {
|
||||
t.Fatalf("unexpected error on resume: %v", readErr)
|
||||
}
|
||||
|
||||
fullData := append(partialData, remainingData...)
|
||||
if !bytes.Equal(fullData, testData) {
|
||||
t.Fatalf("combined data mismatch: got %d bytes, want %d", len(fullData), len(testData))
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
@@ -204,6 +206,13 @@ func (s3a *S3ApiServer) getEncryptionConfiguration(bucket string) (*s3_pb.Encryp
|
||||
// Get metadata using structured API
|
||||
metadata, err := s3a.GetBucketMetadata(bucket)
|
||||
if err != nil {
|
||||
// If the bucket directory is not found (e.g., during bucket recreation after
|
||||
// a partial delete), treat it as having no encryption configured rather than
|
||||
// failing the upload with an internal error.
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || strings.Contains(err.Error(), "bucket directory not found") {
|
||||
glog.Warningf("getEncryptionConfiguration: bucket metadata not found for %s, treating as no encryption: %v", bucket, err)
|
||||
return nil, s3err.ErrNoSuchBucketEncryptionConfiguration
|
||||
}
|
||||
glog.Errorf("getEncryptionConfiguration: failed to get bucket metadata for bucket %s: %v", bucket, err)
|
||||
return nil, s3err.ErrInternalError
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const (
|
||||
S3_ACTION_PUT_OBJECT = "s3:PutObject"
|
||||
S3_ACTION_DELETE_OBJECT = "s3:DeleteObject"
|
||||
S3_ACTION_DELETE_OBJECT_VERSION = "s3:DeleteObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_VERSION = "s3:GetObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_VERSION = "s3:GetObjectVersion"
|
||||
S3_ACTION_GET_OBJECT_ATTRIBUTES = "s3:GetObjectAttributes"
|
||||
|
||||
// Object ACL operations
|
||||
|
||||
@@ -252,10 +252,11 @@ func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// If collection exists but bucket directory doesn't, this is an inconsistent state
|
||||
// that can occur when a previous bucket deletion partially completed (collection
|
||||
// deletion failed but directory deletion succeeded, or volumes were recreated).
|
||||
// Recover by proceeding to create the missing bucket directory.
|
||||
if collectionExists {
|
||||
glog.Errorf("PutBucketHandler: collection exists but bucket directory missing for %s", bucket)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrBucketAlreadyExists)
|
||||
return
|
||||
glog.Warningf("PutBucketHandler: collection exists but bucket directory missing for %s, recovering by creating bucket directory", bucket)
|
||||
}
|
||||
|
||||
// Check for x-amz-bucket-object-lock-enabled header BEFORE creating bucket
|
||||
@@ -297,6 +298,13 @@ func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
// If mkdir failed because another request created the bucket concurrently,
|
||||
// return BucketAlreadyExists instead of InternalError.
|
||||
if exist, checkErr := s3a.exists(s3a.option.BucketsPath, bucket, true); checkErr == nil && exist {
|
||||
glog.V(3).Infof("PutBucketHandler: bucket %s was created concurrently", bucket)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrBucketAlreadyExists)
|
||||
return
|
||||
}
|
||||
glog.Errorf("PutBucketHandler mkdir: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
return
|
||||
@@ -370,9 +378,18 @@ func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Delete bucket directory first, then collection. This order ensures that if
|
||||
// collection deletion fails, the bucket directory is already gone, preventing
|
||||
// the "collection exists but bucket directory missing" inconsistency that blocks
|
||||
// bucket recreation. An orphaned collection is harmless and will be cleaned up
|
||||
// or reused when the bucket is recreated.
|
||||
err := s3a.rm(s3a.option.BucketsPath, bucket, false, true)
|
||||
if err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
return
|
||||
}
|
||||
|
||||
// delete collection
|
||||
err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
deleteCollectionRequest := &filer_pb.DeleteCollectionRequest{
|
||||
Collection: s3a.getCollectionName(bucket),
|
||||
}
|
||||
@@ -386,15 +403,9 @@ func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
return
|
||||
}
|
||||
|
||||
err = s3a.rm(s3a.option.BucketsPath, bucket, false, true)
|
||||
|
||||
if err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
return
|
||||
// Log but don't fail — the bucket directory is already removed, so the bucket
|
||||
// is effectively deleted. The orphaned collection will be cleaned up or reused.
|
||||
glog.Errorf("DeleteBucketHandler: failed to delete collection for bucket %s: %v", bucket, err)
|
||||
}
|
||||
|
||||
// Clean up bucket-related caches, locks, and metrics after successful deletion
|
||||
@@ -533,6 +544,10 @@ func (s3a *S3ApiServer) autoCreateBucket(r *http.Request, bucket string) error {
|
||||
} else {
|
||||
glog.Warningf("autoCreateBucket: failed to get entry for existing bucket %s: %v", bucket, getErr)
|
||||
}
|
||||
// Remove bucket from negative cache — it exists now
|
||||
if s3a.bucketConfigCache != nil {
|
||||
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, err)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package weed_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
@@ -12,6 +15,16 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// proxyReadConcurrencyPerVolumeServer limits how many concurrent proxy read
|
||||
// requests the filer will issue to any single volume server. Without this,
|
||||
// replication bursts can open hundreds of connections to one volume server,
|
||||
// causing it to drop connections with "unexpected EOF".
|
||||
const proxyReadConcurrencyPerVolumeServer = 16
|
||||
|
||||
var (
|
||||
proxySemaphores sync.Map // host -> chan struct{}
|
||||
)
|
||||
|
||||
func (fs *FilerServer) maybeAddVolumeJwtAuthorization(r *http.Request, fileId string, isWrite bool) {
|
||||
encodedJwt := fs.maybeGetVolumeJwtAuthorizationToken(fileId, isWrite)
|
||||
|
||||
@@ -32,6 +45,29 @@ func (fs *FilerServer) maybeGetVolumeJwtAuthorizationToken(fileId string, isWrit
|
||||
return string(encodedJwt)
|
||||
}
|
||||
|
||||
func acquireProxySemaphore(ctx context.Context, host string) error {
|
||||
v, _ := proxySemaphores.LoadOrStore(host, make(chan struct{}, proxyReadConcurrencyPerVolumeServer))
|
||||
sem := v.(chan struct{})
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func releaseProxySemaphore(host string) {
|
||||
v, ok := proxySemaphores.Load(host)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-v.(chan struct{}):
|
||||
default:
|
||||
glog.Warningf("proxy semaphore for %s was already empty on release", host)
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FilerServer) proxyToVolumeServer(w http.ResponseWriter, r *http.Request, fileId string) {
|
||||
ctx := r.Context()
|
||||
urlStrings, err := fs.filer.MasterClient.GetLookupFileIdFunction()(ctx, fileId)
|
||||
@@ -53,6 +89,15 @@ func (fs *FilerServer) proxyToVolumeServer(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Limit concurrent requests per volume server to prevent overload
|
||||
volumeHost := proxyReq.URL.Host
|
||||
if err := acquireProxySemaphore(ctx, volumeHost); err != nil {
|
||||
glog.V(0).InfofCtx(ctx, "proxy to %s cancelled while waiting: %v", volumeHost, err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer releaseProxySemaphore(volumeHost)
|
||||
|
||||
proxyReq.Header.Set("Host", r.Host)
|
||||
proxyReq.Header.Set("X-Forwarded-For", r.RemoteAddr)
|
||||
request_id.InjectToRequest(ctx, proxyReq)
|
||||
@@ -79,6 +124,8 @@ func (fs *FilerServer) proxyToVolumeServer(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
buf := mem.Allocate(128 * 1024)
|
||||
defer mem.Free(buf)
|
||||
io.CopyBuffer(w, proxyResponse.Body, buf)
|
||||
if _, copyErr := io.CopyBuffer(w, proxyResponse.Body, buf); copyErr != nil {
|
||||
glog.V(0).InfofCtx(ctx, "proxy copy %s: %v", fileId, copyErr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package weed_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProxySemaphore_LimitsConcurrency(t *testing.T) {
|
||||
host := "test-volume:8080"
|
||||
defer proxySemaphores.Delete(host)
|
||||
|
||||
var running atomic.Int32
|
||||
var maxSeen atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Launch more goroutines than the semaphore allows
|
||||
total := proxyReadConcurrencyPerVolumeServer * 3
|
||||
for i := 0; i < total; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := acquireProxySemaphore(context.Background(), host); err != nil {
|
||||
t.Errorf("acquire: %v", err)
|
||||
return
|
||||
}
|
||||
defer releaseProxySemaphore(host)
|
||||
|
||||
cur := running.Add(1)
|
||||
// Track peak concurrency
|
||||
for {
|
||||
old := maxSeen.Load()
|
||||
if cur <= old || maxSeen.CompareAndSwap(old, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
running.Add(-1)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
peak := maxSeen.Load()
|
||||
if peak > int32(proxyReadConcurrencyPerVolumeServer) {
|
||||
t.Fatalf("peak concurrency %d exceeded limit %d", peak, proxyReadConcurrencyPerVolumeServer)
|
||||
}
|
||||
if peak == 0 {
|
||||
t.Fatal("no goroutines ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySemaphore_ContextCancellation(t *testing.T) {
|
||||
host := "test-cancel:8080"
|
||||
defer proxySemaphores.Delete(host)
|
||||
|
||||
// Fill the semaphore
|
||||
for i := 0; i < proxyReadConcurrencyPerVolumeServer; i++ {
|
||||
if err := acquireProxySemaphore(context.Background(), host); err != nil {
|
||||
t.Fatalf("fill acquire: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to acquire with a cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := acquireProxySemaphore(ctx, host)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for i := 0; i < proxyReadConcurrencyPerVolumeServer; i++ {
|
||||
releaseProxySemaphore(host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySemaphore_PerHostIsolation(t *testing.T) {
|
||||
hostA := "volume-a:8080"
|
||||
hostB := "volume-b:8080"
|
||||
defer proxySemaphores.Delete(hostA)
|
||||
defer proxySemaphores.Delete(hostB)
|
||||
|
||||
// Fill hostA's semaphore
|
||||
for i := 0; i < proxyReadConcurrencyPerVolumeServer; i++ {
|
||||
if err := acquireProxySemaphore(context.Background(), hostA); err != nil {
|
||||
t.Fatalf("fill hostA: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// hostB should still be acquirable
|
||||
if err := acquireProxySemaphore(context.Background(), hostB); err != nil {
|
||||
t.Fatalf("hostB should not be blocked by hostA: %v", err)
|
||||
}
|
||||
releaseProxySemaphore(hostB)
|
||||
|
||||
// Clean up hostA
|
||||
for i := 0; i < proxyReadConcurrencyPerVolumeServer; i++ {
|
||||
releaseProxySemaphore(hostA)
|
||||
}
|
||||
}
|
||||
@@ -299,15 +299,24 @@ func (ms *MasterServer) VacuumVolume(ctx context.Context, req *master_pb.VacuumV
|
||||
}
|
||||
|
||||
func (ms *MasterServer) DisableVacuum(ctx context.Context, req *master_pb.DisableVacuumRequest) (*master_pb.DisableVacuumResponse, error) {
|
||||
|
||||
ms.Topo.DisableVacuum()
|
||||
// The caller explicitly indicates whether this disable request comes
|
||||
// from the vacuum plugin monitor. Track ownership so the safety net
|
||||
// in the vacuum loop won't override an operator's intentional disable.
|
||||
if req.GetByPlugin() {
|
||||
ms.Topo.DisableVacuumByPlugin()
|
||||
} else {
|
||||
ms.Topo.DisableVacuum()
|
||||
}
|
||||
resp := &master_pb.DisableVacuumResponse{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (ms *MasterServer) EnableVacuum(ctx context.Context, req *master_pb.EnableVacuumRequest) (*master_pb.EnableVacuumResponse, error) {
|
||||
|
||||
ms.Topo.EnableVacuum()
|
||||
if req.GetByPlugin() {
|
||||
ms.Topo.EnableVacuumByPlugin()
|
||||
} else {
|
||||
ms.Topo.EnableVacuum()
|
||||
}
|
||||
resp := &master_pb.EnableVacuumResponse{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.Se
|
||||
r.HandleFunc("/{fileId}", requestIDMiddleware(ms.redirectHandler))
|
||||
}
|
||||
|
||||
ms.Topo.SetAdminServerConnectedFunc(ms.isAdminServerConnectedFunc)
|
||||
ms.Topo.StartRefreshWritableVolumes(
|
||||
ms.grpcDialOption,
|
||||
ms.option.GarbageThreshold,
|
||||
@@ -336,7 +337,7 @@ func (ms *MasterServer) proxyToLeader(f http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *MasterServer) isAdminServerConnected() bool {
|
||||
func (ms *MasterServer) isAdminServerConnectedFunc() bool {
|
||||
if ms == nil || ms.adminLocks == nil {
|
||||
return false
|
||||
}
|
||||
@@ -383,7 +384,7 @@ func (ms *MasterServer) startAdminScripts() {
|
||||
for {
|
||||
time.Sleep(time.Duration(sleepMinutes) * time.Minute)
|
||||
if ms.Topo.IsLeader() && ms.MasterClient.GetMaster(context.Background()) != "" {
|
||||
if ms.isAdminServerConnected() {
|
||||
if ms.isAdminServerConnectedFunc() {
|
||||
glog.V(1).Infof("Skipping master maintenance scripts because admin server is connected")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -144,38 +144,69 @@ func (vs *VolumeServer) VolumeEcShardsRebuild(ctx context.Context, req *volume_s
|
||||
|
||||
var rebuiltShardIds []uint32
|
||||
|
||||
// Find the rebuild location: the location with the most shards and an .ecx file.
|
||||
// With multi-disk servers, shards may be spread across different locations.
|
||||
var rebuildLocation *storage.DiskLocation
|
||||
var rebuildShardCount int
|
||||
var otherLocationsWithShards []*storage.DiskLocation
|
||||
|
||||
for _, location := range vs.store.Locations {
|
||||
_, _, existingShardCount, err := checkEcVolumeStatus(baseFileName, location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingShardCount == 0 {
|
||||
indexBaseFileName := path.Join(location.IdxDirectory, baseFileName)
|
||||
if !util.FileExists(indexBaseFileName+".ecx") && location.IdxDirectory != location.Directory {
|
||||
indexBaseFileName = path.Join(location.Directory, baseFileName)
|
||||
}
|
||||
hasEcx := util.FileExists(indexBaseFileName + ".ecx")
|
||||
|
||||
// Skip locations that have neither shard files nor an .ecx file.
|
||||
if existingShardCount == 0 && !hasEcx {
|
||||
continue
|
||||
}
|
||||
|
||||
indexBaseFileName := path.Join(location.IdxDirectory, baseFileName)
|
||||
if !util.FileExists(indexBaseFileName+".ecx") && location.IdxDirectory != location.Directory {
|
||||
// .ecx may be in the data directory if created before -dir.idx was configured
|
||||
indexBaseFileName = path.Join(location.Directory, baseFileName)
|
||||
}
|
||||
if util.FileExists(indexBaseFileName + ".ecx") {
|
||||
// write .ec00 ~ .ec13 files
|
||||
dataBaseFileName := path.Join(location.Directory, baseFileName)
|
||||
if generatedShardIds, err := erasure_coding.RebuildEcFiles(dataBaseFileName); err != nil {
|
||||
return nil, fmt.Errorf("RebuildEcFiles %s: %v", dataBaseFileName, err)
|
||||
} else {
|
||||
rebuiltShardIds = generatedShardIds
|
||||
if hasEcx && (rebuildLocation == nil || existingShardCount > rebuildShardCount) {
|
||||
if rebuildLocation != nil {
|
||||
otherLocationsWithShards = append(otherLocationsWithShards, rebuildLocation)
|
||||
}
|
||||
|
||||
if err := erasure_coding.RebuildEcxFile(indexBaseFileName); err != nil {
|
||||
return nil, fmt.Errorf("RebuildEcxFile %s: %v", indexBaseFileName, err)
|
||||
}
|
||||
|
||||
break
|
||||
rebuildLocation = location
|
||||
rebuildShardCount = existingShardCount
|
||||
} else {
|
||||
otherLocationsWithShards = append(otherLocationsWithShards, location)
|
||||
}
|
||||
}
|
||||
|
||||
if rebuildLocation == nil {
|
||||
return &volume_server_pb.VolumeEcShardsRebuildResponse{}, nil
|
||||
}
|
||||
|
||||
// Collect additional directories where shard files may exist.
|
||||
// On multi-disk servers, existing local shards may be on a different disk
|
||||
// than where copied shards were placed during ec.rebuild.
|
||||
rebuildDataDir := rebuildLocation.Directory
|
||||
var additionalDirs []string
|
||||
for _, otherLocation := range otherLocationsWithShards {
|
||||
additionalDirs = append(additionalDirs, otherLocation.Directory)
|
||||
}
|
||||
|
||||
// Rebuild missing EC files, searching all disk locations for input shards
|
||||
dataBaseFileName := path.Join(rebuildDataDir, baseFileName)
|
||||
if generatedShardIds, err := erasure_coding.RebuildEcFiles(dataBaseFileName, additionalDirs...); err != nil {
|
||||
return nil, fmt.Errorf("RebuildEcFiles %s: %v", dataBaseFileName, err)
|
||||
} else {
|
||||
rebuiltShardIds = generatedShardIds
|
||||
}
|
||||
|
||||
indexBaseFileName := path.Join(rebuildLocation.IdxDirectory, baseFileName)
|
||||
if !util.FileExists(indexBaseFileName+".ecx") && rebuildLocation.IdxDirectory != rebuildLocation.Directory {
|
||||
indexBaseFileName = path.Join(rebuildLocation.Directory, baseFileName)
|
||||
}
|
||||
if err := erasure_coding.RebuildEcxFile(indexBaseFileName); err != nil {
|
||||
return nil, fmt.Errorf("RebuildEcxFile %s: %v", indexBaseFileName, err)
|
||||
}
|
||||
|
||||
return &volume_server_pb.VolumeEcShardsRebuildResponse{
|
||||
RebuiltShardIds: rebuiltShardIds,
|
||||
}, nil
|
||||
@@ -191,8 +222,20 @@ func (vs *VolumeServer) VolumeEcShardsCopy(ctx context.Context, req *volume_serv
|
||||
|
||||
var location *storage.DiskLocation
|
||||
|
||||
// Use disk_id if provided (disk-aware storage)
|
||||
if req.DiskId > 0 || (req.DiskId == 0 && len(vs.store.Locations) > 0) {
|
||||
// Select the target location for storing EC shard files.
|
||||
//
|
||||
// When req.DiskId > 0 the caller is explicitly choosing a disk:
|
||||
// location = vs.store.Locations[req.DiskId]
|
||||
// (DiskId=1 → Locations[1], DiskId=2 → Locations[2], etc.)
|
||||
//
|
||||
// When req.DiskId == 0 (the protobuf default, meaning "not specified")
|
||||
// we auto-select location by preferring the disk that already holds EC
|
||||
// shards for this volume, then falling back to any HDD, then any disk.
|
||||
//
|
||||
// Note: Locations[0] cannot be targeted explicitly via DiskId because 0
|
||||
// is indistinguishable from "unset". It can still be chosen by the
|
||||
// auto-select logic.
|
||||
if req.DiskId > 0 {
|
||||
// Validate disk ID is within bounds
|
||||
if int(req.DiskId) >= len(vs.store.Locations) {
|
||||
return nil, fmt.Errorf("invalid disk_id %d: only have %d disks", req.DiskId, len(vs.store.Locations))
|
||||
@@ -202,13 +245,21 @@ func (vs *VolumeServer) VolumeEcShardsCopy(ctx context.Context, req *volume_serv
|
||||
location = vs.store.Locations[req.DiskId]
|
||||
glog.V(1).Infof("Using disk %d for EC shard copy: %s", req.DiskId, location.Directory)
|
||||
} else {
|
||||
// Fallback to old behavior for backward compatibility
|
||||
if req.CopyEcxFile {
|
||||
location = vs.store.FindFreeLocation(func(location *storage.DiskLocation) bool {
|
||||
return location.DiskType == types.HardDriveType
|
||||
// Prefer a location that already has shards for this volume,
|
||||
// so all shards end up on the same disk for rebuild.
|
||||
location = vs.store.FindFreeLocation(func(loc *storage.DiskLocation) bool {
|
||||
_, found := loc.FindEcVolume(needle.VolumeId(req.VolumeId))
|
||||
return found
|
||||
})
|
||||
if location == nil {
|
||||
// Fall back to any HDD location with free space
|
||||
location = vs.store.FindFreeLocation(func(loc *storage.DiskLocation) bool {
|
||||
return loc.DiskType == types.HardDriveType
|
||||
})
|
||||
} else {
|
||||
location = vs.store.FindFreeLocation(func(location *storage.DiskLocation) bool {
|
||||
}
|
||||
if location == nil {
|
||||
// Fall back to any location with free space
|
||||
location = vs.store.FindFreeLocation(func(loc *storage.DiskLocation) bool {
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,12 +231,7 @@ func (erb *ecRebuilder) rebuildEcVolumes(collection string) {
|
||||
continue
|
||||
}
|
||||
if shardCount < erasure_coding.DataShardsCount {
|
||||
// Capture variables for closure
|
||||
vid := vid
|
||||
shardCount := shardCount
|
||||
erb.ewg.Add(func() error {
|
||||
return fmt.Errorf("ec volume %d is unrepairable with %d shards", vid, shardCount)
|
||||
})
|
||||
erb.write("ec volume %d is unrepairable with %d shards (need %d), skipping\n", vid, shardCount, erasure_coding.DataShardsCount)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestEcShardMapShardCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildEcVolumesInsufficientShards tests error handling for unrepairable volumes
|
||||
// TestRebuildEcVolumesInsufficientShards tests that unrepairable volumes are skipped
|
||||
func TestRebuildEcVolumesInsufficientShards(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
@@ -101,11 +101,13 @@ func TestRebuildEcVolumesInsufficientShards(t *testing.T) {
|
||||
erb.rebuildEcVolumes("c1")
|
||||
err := erb.ewg.Wait()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for insufficient shards, got nil")
|
||||
// Unrepairable volumes should be skipped (not cause an error)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for unrepairable volume (should be skipped), got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unrepairable") {
|
||||
t.Errorf("Expected 'unrepairable' in error message, got: %s", err.Error())
|
||||
// Verify the skip message was logged
|
||||
if !strings.Contains(logBuffer.String(), "unrepairable") {
|
||||
t.Errorf("Expected 'unrepairable' in log output, got: %s", logBuffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,10 +244,11 @@ func (c *commandFsMergeVolumes) createMergePlan(collection string, toVolumeId ne
|
||||
fmt.Printf("volume %d is not compatible with volume %d\n", src, candidate)
|
||||
continue
|
||||
}
|
||||
if c.getVolumeSizeBasedOnPlan(plan, candidate)+c.getVolumeSizeById(src) > c.volumeSizeLimit {
|
||||
fmt.Printf("volume %d (%d MB) merge into volume %d (%d MB) exceeds volume size limit (%d MB)\n",
|
||||
candidatePlannedSize := c.getVolumeSizeBasedOnPlan(plan, candidate)
|
||||
if candidatePlannedSize+c.getVolumeSizeById(src) > c.volumeSizeLimit {
|
||||
fmt.Printf("volume %d (%d MB) merge into volume %d (%d MB, %d MB with plan) exceeds volume size limit (%d MB)\n",
|
||||
src, c.getVolumeSizeById(src)/1024/1024,
|
||||
candidate, c.getVolumeSizeById(candidate)/1024/1024,
|
||||
candidate, c.getVolumeSizeById(candidate)/1024/1024, candidatePlannedSize/1024/1024,
|
||||
c.volumeSizeLimit/1024/1024)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -253,6 +254,7 @@ func syncFileToRemote(commandEnv *CommandEnv, remoteStorage remote_storage.Remot
|
||||
ctx := context.Background()
|
||||
|
||||
// Update local entry with remote information
|
||||
remoteEntry.LastLocalSyncTsNs = time.Now().UnixNano()
|
||||
localEntry.RemoteEntry = remoteEntry
|
||||
|
||||
// Update the entry
|
||||
|
||||
@@ -2,6 +2,7 @@ package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,6 +18,13 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type MetadataCacheStrategy string
|
||||
|
||||
const (
|
||||
MetadataCacheEager MetadataCacheStrategy = "eager"
|
||||
MetadataCacheLazy MetadataCacheStrategy = "lazy"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Commands = append(Commands, &commandRemoteMount{})
|
||||
}
|
||||
@@ -29,15 +37,19 @@ func (c *commandRemoteMount) Name() string {
|
||||
}
|
||||
|
||||
func (c *commandRemoteMount) Help() string {
|
||||
return `mount remote storage and pull its metadata
|
||||
return `mount remote storage and optionally pull its metadata
|
||||
|
||||
# assume a remote storage is configured to name "cloud1"
|
||||
remote.configure -name=cloud1 -type=s3 -s3.access_key=xxx -s3.secret_key=yyy
|
||||
|
||||
# mount and pull one bucket
|
||||
# mount and pull one bucket (full upfront metadata sync)
|
||||
remote.mount -dir=/xxx -remote=cloud1/bucket
|
||||
# mount without upfront sync; metadata is fetched lazily on access
|
||||
remote.mount -dir=/xxx -remote=cloud1/bucket -metadataStrategy=lazy
|
||||
# mount and pull one directory in the bucket
|
||||
remote.mount -dir=/xxx -remote=cloud1/bucket/dir1
|
||||
# mount with on-demand directory listing cached for 5 minutes
|
||||
remote.mount -dir=/xxx -remote=cloud1/bucket -listingCacheTTL=300
|
||||
|
||||
# after mount, start a separate process to write updates to remote storage
|
||||
weed filer.remote.sync -filer=<filerHost>:<filerPort> -dir=/xxx
|
||||
@@ -55,7 +67,9 @@ func (c *commandRemoteMount) Do(args []string, commandEnv *CommandEnv, writer io
|
||||
|
||||
dir := remoteMountCommand.String("dir", "", "a directory in filer")
|
||||
nonEmpty := remoteMountCommand.Bool("nonempty", false, "allows the mounting over a non-empty directory")
|
||||
metadataStrategy := remoteMountCommand.String("metadataStrategy", string(MetadataCacheEager), "lazy: skip upfront metadata pull; eager: full metadata pull (default)")
|
||||
remote := remoteMountCommand.String("remote", "", "a directory in remote storage, ex. <storageName>/<bucket>/path/to/dir")
|
||||
listingCacheTTL := remoteMountCommand.Int("listingCacheTTL", 0, "seconds to cache remote directory listings (0 = disabled)")
|
||||
|
||||
if err = remoteMountCommand.Parse(args); err != nil {
|
||||
return nil
|
||||
@@ -76,10 +90,21 @@ func (c *commandRemoteMount) Do(args []string, commandEnv *CommandEnv, writer io
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remoteStorageLocation.ListingCacheTtlSeconds = int32(*listingCacheTTL)
|
||||
|
||||
// sync metadata from remote
|
||||
if err = syncMetadata(commandEnv, writer, *dir, *nonEmpty, remoteConf, remoteStorageLocation); err != nil {
|
||||
return fmt.Errorf("pull metadata: %w", err)
|
||||
strategy := MetadataCacheStrategy(strings.ToLower(*metadataStrategy))
|
||||
if strategy != MetadataCacheLazy && strategy != MetadataCacheEager {
|
||||
return fmt.Errorf("metadataStrategy must be %s or %s, got %q", MetadataCacheLazy, MetadataCacheEager, *metadataStrategy)
|
||||
}
|
||||
|
||||
if err = ensureMountDirectory(commandEnv, *dir, *nonEmpty, remoteConf); err != nil {
|
||||
return fmt.Errorf("mount setup: %w", err)
|
||||
}
|
||||
|
||||
if strategy == MetadataCacheEager {
|
||||
if err = pullMetadata(commandEnv, writer, util.FullPath(*dir), remoteStorageLocation, util.FullPath(*dir), remoteConf); err != nil {
|
||||
return fmt.Errorf("cache metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// store a mount configuration in filer
|
||||
@@ -108,17 +133,15 @@ func jsonPrintln(writer io.Writer, message proto.Message) error {
|
||||
return filer.ProtoToText(writer, message)
|
||||
}
|
||||
|
||||
func syncMetadata(commandEnv *CommandEnv, writer io.Writer, dir string, nonEmpty bool, remoteConf *remote_pb.RemoteConf, remote *remote_pb.RemoteStorageLocation) error {
|
||||
|
||||
// find existing directory, and ensure the directory is empty
|
||||
err := commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
func ensureMountDirectory(commandEnv *CommandEnv, dir string, nonEmpty bool, remoteConf *remote_pb.RemoteConf) error {
|
||||
return commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
parent, name := util.FullPath(dir).DirAndName()
|
||||
_, lookupErr := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
_, lookupErr := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parent,
|
||||
Name: name,
|
||||
})
|
||||
if lookupErr != nil {
|
||||
if strings.Contains(lookupErr.Error(), filer_pb.ErrNotFound.Error()) {
|
||||
if errors.Is(lookupErr, filer_pb.ErrNotFound) {
|
||||
_, createErr := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: parent,
|
||||
Entry: &filer_pb.Entry{
|
||||
@@ -127,7 +150,7 @@ func syncMetadata(commandEnv *CommandEnv, writer io.Writer, dir string, nonEmpty
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0644 | os.ModeDir),
|
||||
FileMode: uint32(0755 | os.ModeDir),
|
||||
},
|
||||
RemoteEntry: &filer_pb.RemoteEntry{
|
||||
StorageName: remoteConf.Name,
|
||||
@@ -136,6 +159,7 @@ func syncMetadata(commandEnv *CommandEnv, writer io.Writer, dir string, nonEmpty
|
||||
})
|
||||
return createErr
|
||||
}
|
||||
return lookupErr
|
||||
}
|
||||
|
||||
mountToDirIsEmpty := true
|
||||
@@ -156,16 +180,6 @@ func syncMetadata(commandEnv *CommandEnv, writer io.Writer, dir string, nonEmpty
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// pull metadata from remote
|
||||
if err = pullMetadata(commandEnv, writer, util.FullPath(dir), remote, util.FullPath(dir), remoteConf); err != nil {
|
||||
return fmt.Errorf("cache metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// if an entry has synchronized metadata but has not synchronized content
|
||||
|
||||
@@ -109,9 +109,11 @@ func (c *commandRemoteMountBuckets) Do(args []string, commandEnv *CommandEnv, wr
|
||||
Path: "/",
|
||||
}
|
||||
|
||||
// sync metadata from remote
|
||||
if err = syncMetadata(commandEnv, writer, string(dir), true, remoteConf, remoteStorageLocation); err != nil {
|
||||
return fmt.Errorf("pull metadata on %+v: %v", remoteStorageLocation, err)
|
||||
if err = ensureMountDirectory(commandEnv, string(dir), true, remoteConf); err != nil {
|
||||
return fmt.Errorf("mount setup on %+v: %v", remoteStorageLocation, err)
|
||||
}
|
||||
if err = pullMetadata(commandEnv, writer, dir, remoteStorageLocation, dir, remoteConf); err != nil {
|
||||
return fmt.Errorf("cache metadata on %+v: %v", remoteStorageLocation, err)
|
||||
}
|
||||
|
||||
// store a mount configuration in filer
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/klauspost/reedsolomon"
|
||||
|
||||
@@ -67,7 +68,10 @@ func WriteEcFilesWithContext(baseFileName string, ctx *ECContext) error {
|
||||
return generateEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx)
|
||||
}
|
||||
|
||||
func RebuildEcFiles(baseFileName string) ([]uint32, error) {
|
||||
// RebuildEcFiles rebuilds missing EC shard files.
|
||||
// additionalDirs are extra directories to search for existing shard files,
|
||||
// which handles multi-disk servers where shards may be spread across disks.
|
||||
func RebuildEcFiles(baseFileName string, additionalDirs ...string) ([]uint32, error) {
|
||||
// Attempt to load EC config from .vif file to preserve original configuration
|
||||
var ctx *ECContext
|
||||
if volumeInfo, _, found, _ := volume_info.MaybeLoadVolumeInfo(baseFileName + ".vif"); found && volumeInfo.EcShardConfig != nil {
|
||||
@@ -90,12 +94,13 @@ func RebuildEcFiles(baseFileName string) ([]uint32, error) {
|
||||
ctx = NewDefaultECContext("", 0)
|
||||
}
|
||||
|
||||
return RebuildEcFilesWithContext(baseFileName, ctx)
|
||||
return RebuildEcFilesWithContext(baseFileName, ctx, additionalDirs...)
|
||||
}
|
||||
|
||||
// RebuildEcFilesWithContext rebuilds missing EC files using the provided context
|
||||
func RebuildEcFilesWithContext(baseFileName string, ctx *ECContext) ([]uint32, error) {
|
||||
return generateMissingEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx)
|
||||
// RebuildEcFilesWithContext rebuilds missing EC files using the provided context.
|
||||
// additionalDirs are extra directories to search for existing shard files.
|
||||
func RebuildEcFilesWithContext(baseFileName string, ctx *ECContext, additionalDirs ...string) ([]uint32, error) {
|
||||
return generateMissingEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx, additionalDirs)
|
||||
}
|
||||
|
||||
func ToExt(ecIndex int) string {
|
||||
@@ -122,30 +127,71 @@ func generateEcFiles(baseFileName string, bufferSize int, largeBlockSize int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateMissingEcFiles(baseFileName string, bufferSize int, largeBlockSize int64, smallBlockSize int64, ctx *ECContext) (generatedShardIds []uint32, err error) {
|
||||
// findShardFile looks for a shard file at baseFileName+ext, then in additionalDirs.
|
||||
func findShardFile(baseFileName string, ext string, additionalDirs []string) string {
|
||||
primary := baseFileName + ext
|
||||
if util.FileExists(primary) {
|
||||
return primary
|
||||
}
|
||||
baseName := filepath.Base(baseFileName)
|
||||
for _, dir := range additionalDirs {
|
||||
candidate := filepath.Join(dir, baseName+ext)
|
||||
if util.FileExists(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateMissingEcFiles(baseFileName string, bufferSize int, largeBlockSize int64, smallBlockSize int64, ctx *ECContext, additionalDirs []string) (generatedShardIds []uint32, err error) {
|
||||
|
||||
// Pass 1: discover which shards exist and which are missing,
|
||||
// opening input files but NOT creating output files yet.
|
||||
shardHasData := make([]bool, ctx.Total())
|
||||
shardPaths := make([]string, ctx.Total()) // non-empty for present shards
|
||||
inputFiles := make([]*os.File, ctx.Total())
|
||||
outputFiles := make([]*os.File, ctx.Total())
|
||||
presentCount := 0
|
||||
for shardId := 0; shardId < ctx.Total(); shardId++ {
|
||||
shardFileName := baseFileName + ctx.ToExt(shardId)
|
||||
if util.FileExists(shardFileName) {
|
||||
ext := ctx.ToExt(shardId)
|
||||
shardPath := findShardFile(baseFileName, ext, additionalDirs)
|
||||
if shardPath != "" {
|
||||
shardHasData[shardId] = true
|
||||
inputFiles[shardId], err = os.OpenFile(shardFileName, os.O_RDONLY, 0)
|
||||
shardPaths[shardId] = shardPath
|
||||
inputFiles[shardId], err = os.OpenFile(shardPath, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer inputFiles[shardId].Close()
|
||||
presentCount++
|
||||
} else {
|
||||
outputFiles[shardId], err = os.OpenFile(shardFileName, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer outputFiles[shardId].Close()
|
||||
generatedShardIds = append(generatedShardIds, uint32(shardId))
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-check: bail out before creating any output files.
|
||||
if presentCount < ctx.DataShards {
|
||||
return nil, fmt.Errorf("not enough shards to rebuild %s: found %d shards, need at least %d (data shards), missing shards: %v",
|
||||
baseFileName, presentCount, ctx.DataShards, generatedShardIds)
|
||||
}
|
||||
|
||||
glog.V(0).Infof("rebuilding %s: %d shards present, %d missing %v, config %s",
|
||||
baseFileName, presentCount, len(generatedShardIds), generatedShardIds, ctx.String())
|
||||
|
||||
// Pass 2: create output files for missing shards now that we know
|
||||
// reconstruction is possible.
|
||||
outputFiles := make([]*os.File, ctx.Total())
|
||||
for shardId := 0; shardId < ctx.Total(); shardId++ {
|
||||
if shardHasData[shardId] {
|
||||
continue
|
||||
}
|
||||
outputFileName := baseFileName + ctx.ToExt(shardId)
|
||||
outputFiles[shardId], err = os.OpenFile(outputFileName, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer outputFiles[shardId].Close()
|
||||
}
|
||||
|
||||
err = rebuildEcFiles(shardHasData, inputFiles, outputFiles, ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rebuildEcFiles: %w", err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user