Compare commits

..
Author SHA1 Message Date
Chris Lu a6e07181b2 admin: scrape the metrics ports the cluster advertises
Replaces scraping each node's service port with the dedicated Prometheus
listener each node now advertises. Nodes started without -metricsPort
advertise 0 and are skipped, so nothing is ever fetched from a
client-facing port.

Endpoints are deduplicated by address because a combined "weed server"
advertises one listener for all of its components; series are attributed
by metric name, which already identifies the component.
2026-09-14 23:59:38 -07:00
Chris Lu 51831d6850 admin: derive interval rates and latency quantiles from scrapes
Raw counters and histograms are process-lifetime cumulative, so
charting them directly is meaningless. Counters now become per-second
rates and histograms become p50/p95/p99, both computed from the delta
against the previous scrape, with counter resets skipped. Quantiles use
linear interpolation within the matching bucket, as Prometheus does.
2026-09-14 23:59:38 -07:00
Chris Lu d235dd280b admin: gather the admin's own registry into the metrics store
The admin's maintenance and worker metrics live in the local
stats.Gather registry, so record them directly under the admin/local
source instead of scraping over HTTP. Adds metricsStore.match for
prefix/metric lookups.
2026-09-14 23:59:38 -07:00
Chris Lu 7b9332953d admin: add server-side SVG chart renderer
Extends the existing sparklineSVG approach into a full chart helper
with axes, gridlines, multi-series lines/areas, legends, threshold
lines, and unit formatters (bytes, bps, ms, pct). No JS chart library;
safe to inline in templ pages.
2026-09-14 23:59:38 -07:00
Chris Lu 85147522a9 admin: scrape per-server /metrics into the store
Adds a 15s scrape loop that fetches /metrics from every discovered
master, volume, filer, and S3 server (addresses come from the
existing topology + ListClusterNodes helpers) and records each
series into the in-memory store. Parses Prometheus text exposition
via prometheus/common/expfmt.
2026-09-14 23:59:38 -07:00
Chris Lu 207bc0b75a admin: add in-memory metrics series store
Bounded ring buffer (240 samples) keyed by source/metric[/labels],
reusing the dashSample ring pattern. No persistence; powers the
upcoming monitoring charts.
2026-09-14 23:59:38 -07:00
Chris Lu 2f26d5779b mini: wire the shared metrics port, and tolerate an unset one
weed mini builds MasterOptions directly and never set metricsHttpPort, so
reading it in toMasterOption dereferenced nil and crashed startup. Point
mini's master, volume and filer at its single -metricsPort listener, the
same way weed server does, and treat an unset port as disabled so a
partially initialised MasterOptions cannot panic again.

Reproduced with 'weed mini -dir=... -s3.port=...', which is what the S3
filer-group and delete-regression suites start.
2026-09-14 23:59:32 -07:00
Chris Lu 14bc6e5e4f servers: advertise the configured metricsPort to the master
Filer, S3 and broker report it on the KeepConnected registration, volume
servers in their heartbeat, and masters return their own in
GetMasterConfiguration. MasterClient gains SetMetricsPort so the eight
callers that have no metrics listener are untouched, and the volume
server takes it as a constructor argument because its heartbeat goroutine
starts there.

In combined "weed server" one metrics listener serves the whole shared
registry, so every component advertises the same port.
2026-09-14 23:32:32 -07:00
Chris Lu dfde24f3ee pb: add metrics_port so nodes can advertise their metrics listener
Each server already has a -metricsPort Prometheus listener, but nothing
advertises it, so a central scraper cannot find it. Adds metrics_port to
KeepConnectedRequest and ListClusterNodes (filer, S3, broker), Heartbeat
and DataNodeInfo (volume servers), and GetMasterConfigurationResponse
(masters).

Note the existing metrics_address fields are unrelated: they carry the
Prometheus push gateway that servers push to, not a scrape target.
2026-09-14 23:23:25 -07:00
411 changed files with 8964 additions and 18972 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.38.1
uses: github/codeql-action/init@v4.38.0
# Override language selection by uncommenting this and choosing your languages
with:
languages: go
@@ -35,7 +35,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v4.38.1
uses: github/codeql-action/autobuild@v4.38.0
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -49,4 +49,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.38.1
uses: github/codeql-action/analyze@v4.38.0
+1 -2
View File
@@ -6,7 +6,6 @@ on:
paths:
- 'weed/**'
- 'seaweed-volume/**'
- 'seaweed-common/**'
- 'seaweed-worker/**'
- 'docker/**'
- 'go.mod'
@@ -153,7 +152,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
uses: docker/setup-qemu-action@v4.4.0
uses: docker/setup-qemu-action@v4.3.0
- name: Create BuildKit config
run: |
@@ -129,7 +129,7 @@ jobs:
echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4.4.0
uses: docker/setup-qemu-action@v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+2 -2
View File
@@ -236,7 +236,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
if: matrix.platform != 'amd64'
uses: docker/setup-qemu-action@v4.4.0
uses: docker/setup-qemu-action@v4.3.0
- name: Create BuildKit config
run: |
cat > /tmp/buildkitd.toml <<EOF
@@ -405,7 +405,7 @@ jobs:
output: trivy-results.sarif
exit-code: '0'
- name: Upload Trivy scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v4.38.1
uses: github/codeql-action/upload-sarif@v4.38.0
if: always()
with:
sarif_file: trivy-results.sarif
@@ -46,7 +46,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4.4.0
uses: docker/setup-qemu-action@v4.3.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -231,7 +231,7 @@ jobs:
- name: Set up QEMU
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && matrix.qemu
uses: docker/setup-qemu-action@v4.4.0
uses: docker/setup-qemu-action@v4.3.0
- name: Create BuildKit config
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
@@ -456,7 +456,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v4.38.1
uses: github/codeql-action/upload-sarif@v4.38.0
with:
sarif_file: trivy-results.sarif
category: trivy-${{ matrix.variant }}
@@ -85,7 +85,7 @@ jobs:
echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v1
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v1
+35 -45
View File
@@ -35,48 +35,11 @@ jobs:
cd telemetry/server
go mod tidy
echo "Building telemetry server..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../../telemetry-server .
GOOS=linux GOARCH=amd64 go build -o ../../telemetry-server .
cd ../..
ls -la telemetry-server
echo "Build completed successfully"
- name: Generate Service Configuration
if: github.event_name == 'workflow_dispatch' && (inputs.setup || inputs.deploy)
env:
REMOTE_USER: ${{ secrets.TELEMETRY_USER }}
run: |
# Create systemd service file
echo "
[Unit]
Description=SeaweedFS Telemetry Server
After=network.target
[Service]
Type=simple
User=$REMOTE_USER
WorkingDirectory=/home/$REMOTE_USER/seaweedfs-telemetry
ExecStart=/bin/sh -c 'exec /home/$REMOTE_USER/seaweedfs-telemetry/bin/telemetry-server -port=8353 >>/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.log 2>>/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.error.log'
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target" > telemetry.service
# Setup logrotate configuration
echo "# SeaweedFS Telemetry service log rotation
/home/$REMOTE_USER/seaweedfs-telemetry/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 644 $REMOTE_USER $REMOTE_USER
postrotate
systemctl restart telemetry.service
endscript
}" > telemetry_logrotate
- name: First-time Server Setup
if: github.event_name == 'workflow_dispatch' && inputs.setup
env:
@@ -98,6 +61,40 @@ jobs:
touch ~/seaweedfs-telemetry/logs/telemetry.log ~/seaweedfs-telemetry/logs/telemetry.error.log && \
chmod 644 ~/seaweedfs-telemetry/logs/*.log"
# Create systemd service file
echo "
[Unit]
Description=SeaweedFS Telemetry Server
After=network.target
[Service]
Type=simple
User=$REMOTE_USER
WorkingDirectory=/home/$REMOTE_USER/seaweedfs-telemetry
ExecStart=/home/$REMOTE_USER/seaweedfs-telemetry/bin/telemetry-server -port=8353
Restart=always
RestartSec=5
StandardOutput=append:/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.log
StandardError=append:/home/$REMOTE_USER/seaweedfs-telemetry/logs/telemetry.error.log
[Install]
WantedBy=multi-user.target" > telemetry.service
# Setup logrotate configuration
echo "# SeaweedFS Telemetry service log rotation
/home/$REMOTE_USER/seaweedfs-telemetry/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 644 $REMOTE_USER $REMOTE_USER
postrotate
systemctl restart telemetry.service
endscript
}" > telemetry_logrotate
# Copy configuration files
scp -i ~/.ssh/deploy_key telemetry/grafana-dashboard.json $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
scp -i ~/.ssh/deploy_key telemetry/prometheus.yml $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
@@ -140,18 +137,11 @@ jobs:
scp -i ~/.ssh/deploy_key telemetry/grafana-dashboard.json $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
scp -i ~/.ssh/deploy_key telemetry/prometheus.yml $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
# Copy updated service and logrotate files
scp -i ~/.ssh/deploy_key telemetry.service telemetry_logrotate $REMOTE_USER@$REMOTE_HOST:~/seaweedfs-telemetry/
# Check if service exists and deploy accordingly
ssh -i ~/.ssh/deploy_key $REMOTE_USER@$REMOTE_HOST "
if systemctl list-unit-files telemetry.service >/dev/null 2>&1; then
echo 'Service exists, performing update...'
set -e
sudo systemctl stop telemetry.service
sudo mv ~/seaweedfs-telemetry/telemetry.service /etc/systemd/system/
sudo mv ~/seaweedfs-telemetry/telemetry_logrotate /etc/logrotate.d/seaweedfs-telemetry
sudo systemctl daemon-reload
mkdir -p ~/seaweedfs-telemetry/bin
mv ~/seaweedfs-telemetry/tmp/telemetry-server ~/seaweedfs-telemetry/bin/
chmod +x ~/seaweedfs-telemetry/bin/telemetry-server
-14
View File
@@ -116,20 +116,6 @@ jobs:
grep -q "security-config" /tmp/security.yaml
echo "Security configuration renders correctly"
echo ""
echo "=== Testing admin.allowInsecureBind satisfies the admin auth render guard ==="
helm template test $CHART_DIR --set admin.enabled=true --set admin.allowInsecureBind=true \
> /tmp/admin-allow-insecure-bind.yaml
grep -q -- "-allowInsecureBind" /tmp/admin-allow-insecure-bind.yaml
echo "admin.allowInsecureBind renders -allowInsecureBind and passes the render guard"
if helm template test $CHART_DIR --set admin.enabled=true > /tmp/admin-no-auth.yaml 2>/tmp/admin-no-auth.err; then
echo "FAIL: admin.enabled=true with no auth configured should fail to render"
exit 1
fi
grep -q "admin.allowInsecureBind" /tmp/admin-no-auth.err
echo "admin with no auth configured still fails the render guard, and the guard mentions admin.allowInsecureBind"
echo ""
echo "=== Testing JWT expiration overrides ==="
helm template test $CHART_DIR \
-1
View File
@@ -8,7 +8,6 @@ on:
- 'go.mod'
- 'go.sum'
- 'seaweed-volume/**'
- 'seaweed-common/**'
- 'test/perf/**'
- '.github/workflows/performance.yml'
workflow_dispatch:
+3 -82
View File
@@ -5,7 +5,6 @@ on:
branches: [ master ]
paths:
- 'seaweed-volume/**'
- 'seaweed-common/**'
- 'test/volume_server/**'
- 'weed/pb/volume_server.proto'
- 'weed/pb/volume_server_pb/**'
@@ -14,7 +13,6 @@ on:
branches: [ master, main ]
paths:
- 'seaweed-volume/**'
- 'seaweed-common/**'
- 'test/volume_server/**'
- 'weed/pb/volume_server.proto'
- 'weed/pb/volume_server_pb/**'
@@ -29,29 +27,6 @@ permissions:
jobs:
changes:
name: Detect changed paths
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
rust: ${{ steps.filter.outputs.rust }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Filter changed paths
id: filter
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'seaweed-volume/**'
- '.github/workflows/rust-volume-server-tests.yml'
rust-unit-tests:
name: Rust Unit Tests
runs-on: ubuntu-22.04
@@ -77,7 +52,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
seaweed-volume/target
key: rust-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock', 'seaweed-common/Cargo.lock') }}
key: rust-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock') }}
restore-keys: |
rust-${{ steps.toolchain.outputs.fingerprint }}-
@@ -90,60 +65,6 @@ jobs:
# - name: Clippy
# run: cd seaweed-volume && cargo clippy --all-targets -- -D warnings
# The crate is rustfmt-clean as of the PR that added this step.
# Uncomment to keep it that way.
# - name: Check formatting
# run: cd seaweed-volume && cargo fmt --check
# seaweed-common is a path dependency of this crate, not a member of its
# workspace, so the run below does not reach its own tests. It builds into
# this job's cached target directory, and the cache key above covers the
# shared crate's lock, so the aws-lc-sys that rustls pulls in is restored
# with the cache instead of compiled from scratch on every run.
- name: Run shared-crate unit tests
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/seaweed-volume/target
run: cd seaweed-common && cargo test
- name: Run Rust unit tests
run: cd seaweed-volume && cargo test
- name: Run Rust unit tests (redb experimental cursor)
run: cd seaweed-volume && cargo test --features redb-experimental-cursor --lib storage::needle_map
rust-unit-tests-windows:
name: Rust Unit Tests (Windows)
runs-on: windows-latest
timeout-minutes: 30
needs: [changes]
if: needs.changes.outputs.rust == 'true'
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
# No glibc on Windows: key the cache on the toolchain and OS only.
- name: Fingerprint build toolchain
id: toolchain
run: echo "fingerprint=windows-rustc-$(rustc -V | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache cargo registry and target
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
seaweed-volume/target
key: rust-windows-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock') }}
restore-keys: |
rust-windows-${{ steps.toolchain.outputs.fingerprint }}-
- name: Run Rust unit tests
run: cd seaweed-volume && cargo test
@@ -180,7 +101,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
seaweed-volume/target
key: rust-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock', 'seaweed-common/Cargo.lock') }}
key: rust-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock') }}
restore-keys: |
rust-${{ steps.toolchain.outputs.fingerprint }}-
@@ -262,7 +183,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
seaweed-volume/target
key: rust-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock', 'seaweed-common/Cargo.lock') }}
key: rust-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-volume/Cargo.lock') }}
restore-keys: |
rust-${{ steps.toolchain.outputs.fingerprint }}-
+1 -19
View File
@@ -5,14 +5,12 @@ on:
branches: [ master ]
paths:
- 'seaweed-worker/**'
- 'seaweed-common/**'
- 'weed/pb/plugin.proto'
- '.github/workflows/rust-worker-tests.yml'
push:
branches: [ master, main ]
paths:
- 'seaweed-worker/**'
- 'seaweed-common/**'
- 'weed/pb/plugin.proto'
- '.github/workflows/rust-worker-tests.yml'
@@ -51,7 +49,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
seaweed-worker/target/release
key: rust-worker-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-worker/Cargo.lock', 'seaweed-common/Cargo.lock') }}
key: rust-worker-${{ steps.toolchain.outputs.fingerprint }}-${{ hashFiles('seaweed-worker/Cargo.lock') }}
restore-keys: |
rust-worker-${{ steps.toolchain.outputs.fingerprint }}-
@@ -81,22 +79,6 @@ jobs:
# - name: Clippy
# run: cd seaweed-worker && cargo clippy --workspace --all-targets -- -D warnings
# The workspace is rustfmt-clean as of the PR that added this step.
# Uncomment to keep it that way.
# - name: Check formatting
# run: cd seaweed-worker && cargo fmt --all --check
# seaweed-common is a path dependency of core and lance, not a member of
# this workspace, so `--workspace` below does not reach its own tests.
# Release and this job's cached target directory, and the cache key above
# covers the shared crate's lock. That lock pins the same rustls and
# aws-lc-sys this workspace resolves, so the release build above has
# already paid for them.
- name: Run shared-crate unit tests
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/seaweed-worker/target
run: cd seaweed-common && cargo test --release
# The tests that need a live gateway skip themselves without one, the way
# the Go integration tests skip without Docker; the lifecycle suite in
# test/s3tables/lifecycle is what runs them against a real cluster.
-1
View File
@@ -5,7 +5,6 @@ on:
branches: [ master ]
paths:
- 'seaweed-volume/**'
- 'seaweed-common/**'
- '.github/workflows/rust_binaries_dev.yml'
permissions:
-106
View File
@@ -1,106 +0,0 @@
name: "Snowflake S3Compat API tests"
on:
push:
branches: [ master ]
paths:
- 'weed/s3api/**'
- 'weed/filer/**'
- 'weed/server/**'
- 'weed/iam/**'
- 'weed/command/**'
- 'weed/storage/**'
- 'weed/operation/**'
- 'weed/wdclient/**'
- 'weed/cluster/**'
- 'weed/pb/**'
- 'test/s3/snowflake/**'
- 'go.mod'
- 'go.sum'
- '.github/workflows/s3-snowflake-tests.yml'
pull_request:
branches: [ master ]
paths:
- 'weed/s3api/**'
- 'weed/filer/**'
- 'weed/server/**'
- 'weed/iam/**'
- 'weed/command/**'
- 'weed/storage/**'
- 'weed/operation/**'
- 'weed/wdclient/**'
- 'weed/cluster/**'
- 'weed/pb/**'
- 'test/s3/snowflake/**'
- 'go.mod'
- 'go.sum'
- '.github/workflows/s3-snowflake-tests.yml'
concurrency:
group: ${{ github.event.pull_request.number || github.ref }}/s3-snowflake-tests
cancel-in-progress: true
permissions:
contents: read
jobs:
snowflake-s3compat-tests:
name: Snowflake S3Compat API tests
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
WORK_DIR: /tmp/seaweedfs-snowflake-tests
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
id: go
- name: Set up Java
uses: actions/setup-java@v6
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
- name: Install SeaweedFS
run: |
cd weed
go install -buildvcs=false
weed version
- name: Run Snowflake S3Compat API tests
timeout-minutes: 20
run: |
# Starts weed server, creates the buckets/objects the suite needs,
# clones the upstream suite, and runs mvn -Dtest=S3CompatApiTest.
bash test/s3/snowflake/run.sh
- name: Show logs on failure
if: failure()
run: |
echo "=== SeaweedFS Server Log ==="
tail -200 "$WORK_DIR/weed.log" || echo "No server log"
echo ""
echo "=== Surefire results ==="
cat "$WORK_DIR"/snowflake-s3compat-api-test-suite/s3compatapi/target/surefire-reports/*.txt 2>/dev/null || echo "No surefire reports"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: snowflake-s3compat-surefire-reports
path: /tmp/seaweedfs-snowflake-tests/snowflake-s3compat-api-test-suite/s3compatapi/target/surefire-reports/
retention-days: 14
- name: Cleanup
if: always()
run: |
pkill -9 -f "weed server" || true
rm -rf "$WORK_DIR" || true
+3 -1
View File
@@ -17,7 +17,7 @@ SeaweedFS is a simple and highly scalable distributed file system. There are two
1. to store billions of files!
2. to serve the files fast!
One `weed` binary serves an S3 object store, a POSIX file system, and a lakehouse with S3 Tables, all over the same data. Each blob is one disk read away, capacity grows by starting another volume server, and cloud storage can be cached or tiered transparently. Both read and write operations have O(1) complexity and can run at the full speed supported by the underlying hardware.
One `weed` binary serves an S3 object store, a POSIX file system, and a lakehouse with S3 Tables, all over the same data. Each blob is one disk read away, capacity grows by starting another volume server, and cloud storage can be cached or tiered transparently.
- [Download Binaries for different platforms](https://github.com/seaweedfs/seaweedfs/releases/latest)
- [Wiki Documentation](https://github.com/seaweedfs/seaweedfs/wiki)
@@ -400,6 +400,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The text of this page is available for modification and reuse under the terms of the Creative Commons Attribution-Sharealike 3.0 Unported License and the GNU Free Documentation License (unversioned, with no invariant sections, front-cover texts, or back-cover texts).
[Back to TOC](#table-of-contents)
# Sponsors #
-6
View File
@@ -14,9 +14,6 @@ RUN cd /go/src/github.com/seaweedfs/seaweedfs && \
git checkout $BRANCH) || \
(echo "ERROR: Branch/commit $BRANCH not found in repository" && \
echo "Available branches:" && git branch -a && exit 1))
# seaweed-common only exists on revisions that have it; a BRANCH predating it
# still needs the directory so the COPY into rust_builder below never fails.
RUN mkdir -p /go/src/github.com/seaweedfs/seaweedfs/seaweed-common
ARG TARGETOS TARGETARCH TARGETVARIANT
RUN cd /go/src/github.com/seaweedfs/seaweedfs/weed \
&& export LDFLAGS="-X github.com/seaweedfs/seaweedfs/weed/util/version.COMMIT=$(git rev-parse --short HEAD)" \
@@ -34,9 +31,6 @@ ARG TAGS
COPY weed-volume-prebuilt/ /prebuilt/
COPY weed-worker-prebuilt/ /prebuilt-worker/
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/seaweed-volume /build/seaweed-volume
# seaweed-common is a path dependency of seaweed-volume that lives beside it,
# so the source build below needs it in the same relative position.
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/seaweed-common /build/seaweed-common
COPY --from=builder /go/src/github.com/seaweedfs/seaweedfs/weed /build/weed
WORKDIR /build/seaweed-volume
RUN if [ -f "/prebuilt/weed-volume-${TARGETARCH}" ]; then \
+2 -5
View File
@@ -1,14 +1,11 @@
FROM alpine:latest
# Install required packages
RUN apk upgrade --no-cache && \
apk add --no-cache \
RUN apk add --no-cache \
ca-certificates \
fuse \
curl \
jq \
libcrypto3 \
libssl3
jq
# Copy our locally built binary
COPY weed-local /usr/bin/weed
-2
View File
@@ -30,5 +30,3 @@ sleep_minutes = 17 # sleep minutes between each script execution
bucket = "volume_bucket" # an existing bucket
endpoint = "http://server2:8333"
storage_class = "STANDARD_IA"
# upload_concurrency = 5 # concurrent multipart part uploads per volume (volume.tier.upload -concurrent overrides)
# download_concurrency = 5 # concurrent multipart part downloads per volume (volume.tier.download -concurrent overrides)
+23 -23
View File
@@ -1,6 +1,6 @@
module github.com/seaweedfs/seaweedfs
go 1.26.6
go 1.26.0
require (
cloud.google.com/go v0.123.0 // indirect
@@ -60,7 +60,7 @@ require (
github.com/pquerna/cachecontrol v0.2.0
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/common v0.70.1
github.com/prometheus/procfs v0.22.0
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -70,7 +70,7 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/stretchr/testify v1.11.1
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965
github.com/tidwall/gjson v1.18.0
@@ -90,7 +90,7 @@ require (
gocloud.dev v0.46.0
gocloud.dev/pubsub/natspubsub v0.46.0
gocloud.dev/pubsub/rabbitpubsub v0.46.0
golang.org/x/crypto v0.57.0
golang.org/x/crypto v0.56.0
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597
golang.org/x/image v0.46.0
golang.org/x/net v0.58.0
@@ -101,7 +101,7 @@ require (
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.297.0
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/grpc v1.85.0-dev.0.20260915183914-4e49413dcab7
google.golang.org/grpc v1.85.0-dev
google.golang.org/protobuf v1.36.12
gopkg.in/inf.v0 v0.9.1 // indirect
modernc.org/b v1.0.0 // indirect
@@ -111,7 +111,7 @@ require (
)
require (
cloud.google.com/go/kms v1.34.0
cloud.google.com/go/kms v1.33.0
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Jille/raft-grpc-transport v1.6.1
@@ -151,7 +151,7 @@ require (
github.com/seaweedfs/go-fuse/v2 v2.9.4
github.com/shirou/gopsutil/v4 v4.26.7
github.com/tarantool/go-option v1.1.0
github.com/tarantool/go-tarantool/v3 v3.0.2
github.com/tarantool/go-tarantool/v3 v3.0.1
github.com/testcontainers/testcontainers-go v0.44.0
github.com/tikv/client-go/v2 v2.0.7
github.com/twmb/avro v1.8.0
@@ -159,7 +159,7 @@ require (
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.2
github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1
go.etcd.io/etcd/client/pkg/v3 v3.7.1
go.uber.org/atomic v1.12.0
go.uber.org/atomic v1.11.0
golang.org/x/sync v0.23.0
golang.org/x/tools/godoc v0.1.0-deprecated
google.golang.org/grpc/security/advancedtls v1.0.0
@@ -283,19 +283,19 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.41.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
)
require (
cel.dev/expr v0.25.3 // indirect
cel.dev/expr v0.25.2 // indirect
cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
@@ -310,7 +310,7 @@ require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/Files-com/files-sdk-go/v3 v3.3.194 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/IBM/go-sdk-core/v5 v5.23.1 // indirect
@@ -340,7 +340,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0
github.com/aws/smithy-go v1.28.1
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
@@ -363,7 +363,7 @@ require (
github.com/elastic/gosigar v0.14.3 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
@@ -388,7 +388,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.21 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0
@@ -438,7 +438,7 @@ require (
github.com/oracle/oci-go-sdk/v65 v65.121.0 // indirect
github.com/panjf2000/ants/v2 v2.12.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.4.1 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.29
@@ -489,9 +489,9 @@ require (
go.etcd.io/bbolt v1.5.0 // indirect
go.etcd.io/etcd/api/v3 v3.7.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.45.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
@@ -499,9 +499,9 @@ require (
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/term v0.46.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
+42 -42
View File
@@ -6,8 +6,8 @@ atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -298,8 +298,8 @@ cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxs
cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg=
cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0=
cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w=
cloud.google.com/go/kms v1.34.0 h1:mxWcXEiyjxwFH5gclulLx+B8Y2OEpKJRZ5FOF78c2XE=
cloud.google.com/go/kms v1.34.0/go.mod h1:FbxZWUiihmyjxlaBha84OK5+fmJHPrS6F5/mBFdJk6A=
cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo=
cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA=
cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI=
cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE=
@@ -593,8 +593,8 @@ github.com/FilenCloudDienste/filen-sdk-go v0.0.39 h1:tgV5jYL6dsXop9TpDTIQU6UwJjw
github.com/FilenCloudDienste/filen-sdk-go v0.0.39/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.3.194 h1:dtOFxSTWWRpkmvXa6ycNiw8dVDu1wkgzcXyVV1VafNc=
github.com/Files-com/files-sdk-go/v3 v3.3.194/go.mod h1:rl0WumSN9gSo775DgvQv+wMQ8rlb0ES/1hU5jkMtLXg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 h1:yzIYdwuro811Z27D3T80Wkd3rqZzb0K43nner7Eh1yE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
@@ -750,8 +750,8 @@ github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtX
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 h1:Zpnqa6XtrNzXZnwbdCqHOXpXhMsa01ql/pcRQ1sb4hk=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -987,8 +987,8 @@ github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJ
github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be h1:SWe0x6yfglnxuvOiYgTTnNq7QD/yvqthOh1RBI8Bj8w=
github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be/go.mod h1:PYEOlng9XcrulfyWpm49jECTPV0LT4q8cO7fLW/xwgk=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@@ -1262,8 +1262,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedur3D3z7kI6HiLX9g3TiAN4/DFU=
github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk=
github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -1652,8 +1652,8 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.1 h1:j5OMOImsH+j2k7GJ5YO+RxfWwohNiH6t5zB/+h3bagc=
github.com/pelletier/go-toml/v2 v2.4.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uCmhjJSsY78Mcuh7MVkNjTzmHx1yBzizSU=
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
@@ -1897,8 +1897,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -1918,8 +1918,8 @@ github.com/tarantool/go-iproto v1.1.0 h1:HULVOIHsiehI+FnHfM7wMDntuzUddO09DKqu2Wn
github.com/tarantool/go-iproto v1.1.0/go.mod h1:LNCtdyZxojUed8SbOiYHoc3v9NvaZTB7p96hUySMlIo=
github.com/tarantool/go-option v1.1.0 h1:ShoOhNsdL41sRpm4hXCRDjV8H0WzPkd4UnKhLKbW//w=
github.com/tarantool/go-option v1.1.0/go.mod h1:hMr9z2JXOWlgdCBpCPSL2nwp8718GKYvNBJ+ZuzJbCo=
github.com/tarantool/go-tarantool/v3 v3.0.2 h1:9ZtHllun80QX7KS9tqd3dXa+QAu2BfqFtX1ZWcNnaW8=
github.com/tarantool/go-tarantool/v3 v3.0.2/go.mod h1:TXxLWhUCgdxXFfelnTSkq+goKRTRj660zxq4/WXPe8k=
github.com/tarantool/go-tarantool/v3 v3.0.1 h1:vaUX4xmVmXh2dIJ/LqlX1MXK3iYqAqV6YiE54Wwl/qg=
github.com/tarantool/go-tarantool/v3 v3.0.1/go.mod h1:TXxLWhUCgdxXFfelnTSkq+goKRTRj660zxq4/WXPe8k=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/compose v0.44.0 h1:8YcW51jhgpkkiRVe10Wj9TCBthJmoNpU2fK5WSf7TQ8=
@@ -2103,30 +2103,30 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.45.0 h1:9jR0ZPRok9ryaOQ2Wx8rg5F7Aon59mxrqbVI60/vlBk=
go.opentelemetry.io/contrib/detectors/gcp v1.45.0/go.mod h1:VSme3o2fvSg5bVg0dRzyHaj4Z5EVhG+g2Fde6LKzmQA=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA=
go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY=
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 h1:s0n95ya5tOG03exJ5JySOdJFtwGo4ZQ+KeY7Zro4CLI=
go.opentelemetry.io/otel/exporters/zipkin v1.36.0/go.mod h1:m9wRxtKA2MZ1HcnNC4BKI+9aYe434qRZTCvI7QGUN7Y=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
@@ -2140,13 +2140,13 @@ go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbi
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -2163,8 +2163,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
@@ -2193,8 +2193,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -2491,8 +2491,8 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2805,8 +2805,8 @@ google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q
google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU=
google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 h1:lrupDmKL3p5kEX1M92oan027eCKcouzjuPbH6YBK+Rs=
google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4=
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -2849,8 +2849,8 @@ google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsA
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/grpc v1.85.0-dev.0.20260915183914-4e49413dcab7 h1:5+EEM1fC0yjOZID0NUZVrE2+8M/+1TclNrSz/l1xMYs=
google.golang.org/grpc v1.85.0-dev.0.20260915183914-4e49413dcab7/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM=
google.golang.org/grpc v1.85.0-dev h1:HxkDyKIIZPpFnroC56tQv5gNuKTmVvi0t7TzOf5zt7g=
google.golang.org/grpc v1.85.0-dev/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
+1 -4
View File
@@ -376,10 +376,7 @@ start on a non-loopback address without `-adminPassword`, so the chart fails at
render time if `admin.ip` is non-loopback and authentication is not configured via
`admin.secret.adminPassword`, `admin.secret.existingSecret`, or
`WEED_ADMIN_PASSWORD` supplied through `admin.extraEnvironmentVars` /
`admin.secretExtraEnvironmentVars`. Setting `admin.allowInsecureBind` renders
`-allowInsecureBind` and bypasses this guard; it leaves the admin API
unauthenticated on the network, so use it only when access is otherwise
restricted (e.g. network policies). The whole `127.0.0.0/8` range and `::1` are
`admin.secretExtraEnvironmentVars`. The whole `127.0.0.0/8` range and `::1` are
treated as loopback (matching `weed admin`); `localhost` is treated as
non-loopback. Set `admin.ip` to a loopback address only if you also replace the
httpGet probes (e.g. with an `exec` probe that checks `127.0.0.1`).
@@ -9,7 +9,7 @@
{{- $adminAuthEnabled := include "seaweedfs.admin.authEnabled" . }}
{{- $adminIp := .Values.admin.ip | default "0.0.0.0" }}
{{- if and (not (include "seaweedfs.admin.isLoopbackIp" $adminIp)) (ne $adminAuthEnabled "true") }}
{{- fail (printf "admin.ip is set to %q (non-loopback) but admin authentication is not configured. Since `weed admin` 4.46 refuses to bind a non-loopback address without authentication, the admin container would exit on startup. Set admin.secret.adminPassword or admin.secret.existingSecret, or supply WEED_ADMIN_PASSWORD via admin.extraEnvironmentVars / admin.secretExtraEnvironmentVars, or set admin.ip to a loopback address such as 127.0.0.1 (note: a loopback bind makes the chart's httpGet readiness/liveness probes fail), or set admin.allowInsecureBind to true to opt out via -allowInsecureBind (INSECURE: exposes the admin API unauthenticated on the network)." $adminIp) -}}
{{- fail (printf "admin.ip is set to %q (non-loopback) but admin authentication is not configured. Since `weed admin` 4.46 refuses to bind a non-loopback address without authentication, the admin container would exit on startup. Set admin.secret.adminPassword or admin.secret.existingSecret, or supply WEED_ADMIN_PASSWORD via admin.extraEnvironmentVars / admin.secretExtraEnvironmentVars, or set admin.ip to a loopback address such as 127.0.0.1 (note: a loopback bind makes the chart's httpGet readiness/liveness probes fail)." $adminIp) -}}
{{- end }}
apiVersion: apps/v1
kind: StatefulSet
@@ -176,17 +176,14 @@ spec:
-dataDir={{ .Values.admin.dataDir }} \
{{- end }}
{{- if .Values.admin.masters }}
-masters={{ .Values.admin.masters }} \
-masters={{ .Values.admin.masters }}{{- if or $urlPrefix .Values.admin.extraArgs }} \{{ end }}
{{- else if .Values.global.seaweedfs.masterServer }}
-masters={{ .Values.global.seaweedfs.masterServer }} \
-masters={{ .Values.global.seaweedfs.masterServer }}{{- if or $urlPrefix .Values.admin.extraArgs }} \{{ end }}
{{- else }}
-masters={{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }} \
-masters={{ range $index := until (.Values.master.replicas | int) }}${SEAWEEDFS_FULLNAME}-master-{{ $index }}.${SEAWEEDFS_FULLNAME}-master.{{ $.Release.Namespace }}:{{ $.Values.master.port }}{{ if lt $index (sub ($.Values.master.replicas | int) 1) }},{{ end }}{{ end }}{{- if or $urlPrefix .Values.admin.extraArgs }} \{{ end }}
{{- end }}
{{- if $urlPrefix }}
-urlPrefix={{ $urlPrefix }} \
{{- end }}
{{- if .Values.admin.allowInsecureBind }}
-allowInsecureBind \
-urlPrefix={{ $urlPrefix }}{{- if .Values.admin.extraArgs }} \{{ end }}
{{- end }}
{{- range $index, $arg := .Values.admin.extraArgs }}
{{ $arg }}{{- if lt $index (sub (len $.Values.admin.extraArgs) 1) }} \{{ end }}
@@ -37,17 +37,20 @@ spec:
{{- with .Values.allInOne.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $podAnnotations := mergeOverwrite (deepCopy (.Values.podAnnotations | default dict)) (.Values.allInOne.podAnnotations | default dict) }}
{{- $existingS3ConfigSecret := or .Values.allInOne.s3.existingConfigSecret .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret }}
{{- if $existingS3ConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- end }}
{{- $_ := set $podAnnotations "checksum/master-config" (include (print .Template.BasePath "/master/master-configmap.yaml") . | sha256sum) }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.allInOne.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $existingS3ConfigSecret := or .Values.allInOne.s3.existingConfigSecret .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret }}
{{- if $existingS3ConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
checksum/s3config: {{ $configSecret | toYaml | sha256sum }}
{{- else }}
checksum/s3config: {{ include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum }}
{{- end }}
spec:
restartPolicy: {{ default .Values.global.seaweedfs.restartPolicy .Values.allInOne.restartPolicy }}
{{- if .Values.allInOne.affinity }}
@@ -43,15 +43,19 @@ spec:
{{- with .Values.filer.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $podAnnotations := mergeOverwrite (deepCopy (.Values.podAnnotations | default dict)) (.Values.filer.podAnnotations | default dict) }}
{{- if .Values.filer.s3.existingConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.filer.s3.existingConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- end }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.filer.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.filer.s3.existingConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.filer.s3.existingConfigSecret) | default dict }}
checksum/s3config: {{ $configSecret | toYaml | sha256sum }}
{{- else }}
checksum/s3config: {{ include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum }}
{{- end }}
spec:
restartPolicy: {{ default .Values.global.seaweedfs.restartPolicy .Values.filer.restartPolicy }}
{{- if .Values.filer.affinity }}
@@ -43,10 +43,13 @@ spec:
{{- with .Values.master.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $podAnnotations := mergeOverwrite (deepCopy (.Values.podAnnotations | default dict)) (.Values.master.podAnnotations | default dict) }}
{{- $_ := set $podAnnotations "checksum/master-config" (include (print .Template.BasePath "/master/master-configmap.yaml") . | sha256sum) }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
{{ with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.master.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
restartPolicy: {{ default .Values.global.seaweedfs.restartPolicy .Values.master.restartPolicy }}
{{- if .Values.master.affinity }}
@@ -35,15 +35,19 @@ spec:
{{- with .Values.s3.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $podAnnotations := mergeOverwrite (deepCopy (.Values.podAnnotations | default dict)) (.Values.s3.podAnnotations | default dict) }}
{{- if .Values.s3.existingConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- end }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
{{ with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.s3.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.s3.existingConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
checksum/s3config: {{ $configSecret | toYaml | sha256sum }}
{{- else }}
checksum/s3config: {{ include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum }}
{{- end }}
spec:
restartPolicy: {{ default .Values.global.seaweedfs.restartPolicy .Values.s3.restartPolicy }}
{{- if .Values.s3.affinity }}
@@ -105,13 +105,13 @@ true
{{- end -}}
{{- end -}}
{{/* Whether the admin non-loopback bind guard is satisfied: admin.secret
(adminPassword or existingSecret), WEED_ADMIN_PASSWORD via
extraEnvironmentVars / secretExtraEnvironmentVars, or
admin.allowInsecureBind. A secret-backed entry counts as enabled even
though the chart cannot read its value. */}}
{{/* Whether admin authentication is enabled from any supported source:
admin.secret (adminPassword or existingSecret), or WEED_ADMIN_PASSWORD
supplied via extraEnvironmentVars / secretExtraEnvironmentVars (which
weed admin picks up through viper's AutomaticEnv). A secret-backed
entry counts as enabled even though the chart cannot read its value. */}}
{{- define "seaweedfs.admin.authEnabled" -}}
{{- if or .Values.admin.secret.existingSecret .Values.admin.secret.adminPassword .Values.admin.allowInsecureBind -}}
{{- if or .Values.admin.secret.existingSecret .Values.admin.secret.adminPassword -}}
true
{{- else -}}
{{- $merged := dict -}}
+3 -18
View File
@@ -190,8 +190,6 @@ master:
podLabels: {}
# Annotations to be added to the master pods
# The chart sets checksum/master-config on master pods; other checksum/* keys
# can be used for custom rollouts.
podAnnotations: {}
# Annotations to be added to the master resources
@@ -775,8 +773,6 @@ filer:
podLabels: {}
# Annotations to be added to the filer pods
# The chart sets checksum/s3config on filer pods; other checksum/* keys can be
# used for custom rollouts.
podAnnotations: {}
# Annotations to be added to the filer resource
@@ -1082,8 +1078,6 @@ s3:
podLabels: {}
# Annotations to be added to the s3 pods
# The chart sets checksum/s3config on s3 pods; other checksum/* keys can be
# used for custom rollouts.
podAnnotations: {}
# Annotations to be added to the s3 resources
@@ -1339,11 +1333,10 @@ admin:
# kubelet's httpGet readiness/liveness probes (which dial the pod IP) to ever
# succeed. "0.0.0.0" restores the pre-4.46 behaviour of listening on all
# interfaces. A non-loopback address requires authentication: set
# admin.secret.adminPassword or admin.secret.existingSecret, supply
# admin.secret.adminPassword or admin.secret.existingSecret, or supply
# WEED_ADMIN_PASSWORD via admin.extraEnvironmentVars /
# admin.secretExtraEnvironmentVars, or opt out with admin.allowInsecureBind;
# otherwise the admin container will exit with a clear error rather than
# silently staying unready. The whole
# admin.secretExtraEnvironmentVars; otherwise the admin container will exit
# with a clear error rather than silently staying unready. The whole
# 127.0.0.0/8 range and ::1 are treated as loopback (matching weed admin).
# Set to a loopback address only if you also replace the httpGet probes.
# Note: the -ip flag requires SeaweedFS 4.46 or newer; pinning
@@ -1351,9 +1344,6 @@ admin:
ip: "0.0.0.0"
loggingOverrideLevel: null
# INSECURE: allow binding a non-loopback ip without authentication.
allowInsecureBind: false
# Admin authentication
secret:
# Name of an existing secret containing admin credentials. If set, adminUser and adminPassword below are ignored.
@@ -1798,8 +1788,6 @@ allInOne:
initContainers: "" # Init containers
sidecars: "" # Sidecar containers
annotations: {} # Annotations for the deployment
# The chart sets checksum/master-config and checksum/s3config on all-in-one
# pods; other checksum/* keys can be used for custom rollouts.
podAnnotations: {} # Annotations for the pods
podLabels: {} # Labels for the pods
@@ -1934,9 +1922,6 @@ certificates:
# Labels to be added to all the created pods
podLabels: {}
# Annotations to be added to all the created pods
# The chart sets checksum/master-config and checksum/s3config on pods whose
# rendered ConfigMaps or Secrets should trigger rollouts. Other checksum/* keys
# can be used for custom rollout annotations.
podAnnotations: {}
networkPolicy:
+1027 -1016
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

-293
View File
@@ -1,293 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "cc"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "seaweed-common"
version = "0.1.0"
dependencies = [
"rustls",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
-27
View File
@@ -1,27 +0,0 @@
[package]
name = "seaweed-common"
version = "0.1.0"
edition = "2024"
# The lower of the two consumers' floors (seaweed-volume 1.91.1,
# seaweed-worker 1.94.1), so depending on this crate cannot raise either
# tree's MSRV. Verified with `cargo +1.91.1 check --all-targets`.
rust-version = "1.91.1"
description = "Helpers shared by the SeaweedFS Rust volume server and the Rust plugin workers"
# There is no root manifest: seaweed-volume and seaweed-worker are separate
# cargo trees with their own lockfiles, and this crate is a path dependency of
# both rather than a member of either. Keeping the lint policy identical in all
# three manifests is what stops them drifting.
[lints.clippy]
# Protobuf message literals keep `..Default::default()` on purpose: it is
# what lets a proto gain a field without touching every constructor.
needless_update = "allow"
[dependencies]
# The same requirement both consumers already write. Cargo unifies all
# semver-compatible `rustls = "0.23"` requirements into one crate per binary,
# which is what makes `install_default_crypto_provider` write the same
# process-wide static the consuming crate reads. rustls is already in both
# trees (the volume server directly, seaweed-worker-core through tonic's
# `tls-aws-lc`), so this adds no crate to either graph.
rustls = "0.23"
-307
View File
@@ -1,307 +0,0 @@
//! SeaweedFS server addresses, the way the Go tree does them.
//!
//! An operator gives a SeaweedFS process an HTTP address and the gRPC port is
//! derived from it rather than asked for separately: `host:port` means gRPC on
//! `port + 10000`, and the explicit `host:port.grpcPort` form names it outright.
//! Dialling the HTTP port by mistake fails as "frame with invalid size", which
//! reads like a protocol bug rather than a wrong port, so the rule is worth its
//! own module. Mirrors `pb.ServerToGrpcAddress` in
//! `weed/pb/grpc_client_server.go`.
//!
//! The volume server and the workers each had their own copy of this and the
//! copies had drifted: the worker's bracketed IPv6 literals and the volume
//! server's did not, so `::1:19333` produced `::1:29333`, which the HTTP
//! authority parser rejects. One implementation, two thin wrappers.
use std::fmt;
use std::num::ParseIntError;
/// SeaweedFS's HTTP↔gRPC port-offset convention.
pub const GRPC_PORT_OFFSET: u16 = 10000;
/// Why an address could not be turned into a gRPC address.
///
/// The `Display` text is the volume server's original wording, because its
/// `parse_grpc_address` wrapper hands it straight to callers that put it in a
/// `Status` or an `io::Error`.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AddressError {
/// No `:` at all, so there is no port to translate.
MissingPort(String),
/// The HTTP port of the `host:port.grpcPort` form is not a `u16`. It is
/// validated even though it is then discarded, so that a malformed address
/// is rejected here instead of failing later as an opaque connect error.
InvalidHttpPort { port: String, source: ParseIntError },
/// The gRPC port of the `host:port.grpcPort` form is not a `u16`.
InvalidGrpcPort { port: String, source: ParseIntError },
/// The port of the `host:port` form is not a `u16`.
InvalidPort { port: String, source: ParseIntError },
/// `port + GRPC_PORT_OFFSET` leaves the TCP port range, e.g. `host:60000`.
/// Without the check the cast would wrap silently.
ImplicitGrpcPortOutOfRange(u16),
}
impl fmt::Display for AddressError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingPort(address) => write!(f, "cannot parse address: {address}"),
Self::InvalidHttpPort { port, source } => {
write!(f, "invalid http port {port:?}: {source}")
}
Self::InvalidGrpcPort { port, source } => {
write!(f, "invalid grpc port {port:?}: {source}")
}
Self::InvalidPort { port, source } => write!(f, "invalid port {port:?}: {source}"),
Self::ImplicitGrpcPortOutOfRange(port) => write!(
f,
"implicit grpc port out of range: {port} + {GRPC_PORT_OFFSET} = {}",
u32::from(*port) + u32::from(GRPC_PORT_OFFSET)
),
}
}
}
impl std::error::Error for AddressError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidHttpPort { source, .. }
| Self::InvalidGrpcPort { source, .. }
| Self::InvalidPort { source, .. } => Some(source),
Self::MissingPort(_) | Self::ImplicitGrpcPortOutOfRange(_) => None,
}
}
}
/// Turn a SeaweedFS server address (`"host:port.grpcPort"` or `"host:port"`)
/// into the `host:grpcPort` form the endpoint builders expect.
///
/// With the trailing `.grpcPort` segment that segment *is* the gRPC port;
/// without it the gRPC port is `port + GRPC_PORT_OFFSET`. An unbracketed IPv6
/// literal comes back bracketed, because otherwise the port reads as part of
/// the address.
pub fn to_grpc_address(server: &str) -> Result<String, AddressError> {
// rfind, not find: an IPv6 literal is full of colons and the port is after
// the last one.
let colon_idx = server
.rfind(':')
.ok_or_else(|| AddressError::MissingPort(server.to_string()))?;
let host = &server[..colon_idx];
let port_part = &server[colon_idx + 1..];
// rfind again rather than split_once: the host may be an IPv4 address, and
// only the part after the last colon is being split here anyway.
if let Some(dot_idx) = port_part.rfind('.') {
let http_port = &port_part[..dot_idx];
let grpc_port = &port_part[dot_idx + 1..];
http_port
.parse::<u16>()
.map_err(|source| AddressError::InvalidHttpPort {
port: http_port.to_string(),
source,
})?;
let grpc_port =
grpc_port
.parse::<u16>()
.map_err(|source| AddressError::InvalidGrpcPort {
port: grpc_port.to_string(),
source,
})?;
return Ok(join_host_port(host, grpc_port));
}
let port: u16 = port_part
.parse()
.map_err(|source| AddressError::InvalidPort {
port: port_part.to_string(),
source,
})?;
let grpc_port = port
.checked_add(GRPC_PORT_OFFSET)
.ok_or(AddressError::ImplicitGrpcPortOutOfRange(port))?;
Ok(join_host_port(host, grpc_port))
}
/// Join a host and a port, bracketing an IPv6 literal that is not bracketed
/// already. Public because the address rule is not the only place that has to
/// put a host and a port back together.
pub fn join_host_port(host: &str, port: u16) -> String {
// An IPv6 literal has to keep its brackets or the port reads as part of it.
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
#[cfg(test)]
mod tests {
use super::{AddressError, GRPC_PORT_OFFSET, join_host_port, to_grpc_address};
// ---- the volume server's cases -------------------------------------
#[test]
fn dotted_form_states_the_grpc_port() {
assert_eq!(
to_grpc_address("127.0.0.1:8080.18080").unwrap(),
"127.0.0.1:18080"
);
assert_eq!(
to_grpc_address("192.168.1.66:8080.18080").unwrap(),
"192.168.1.66:18080"
);
}
#[test]
fn implicit_form_adds_the_offset() {
assert_eq!(
to_grpc_address("127.0.0.1:8080").unwrap(),
"127.0.0.1:18080"
);
assert_eq!(
to_grpc_address("192.168.1.66:8080").unwrap(),
"192.168.1.66:18080"
);
assert_eq!(
to_grpc_address("localhost:9333").unwrap(),
"localhost:19333"
);
}
#[test]
fn the_dotted_grpc_port_comes_back_normalised() {
// The volume server's copy validated this segment as a u16 and then
// emitted the original text, so a padded or signed port produced an
// authority the URI parser rejects. The parsed value is emitted now.
assert_eq!(to_grpc_address("host:8080.018080").unwrap(), "host:18080");
assert_eq!(to_grpc_address("host:8080.+18080").unwrap(), "host:18080");
}
#[test]
fn an_ipv4_host_is_not_confused_with_the_dotted_port() {
// Regression: a naive split on '.' breaks on IP addresses.
assert_eq!(
to_grpc_address("10.0.0.1:8080.18080").unwrap(),
"10.0.0.1:18080"
);
assert_eq!(to_grpc_address("10.0.0.1:8080").unwrap(), "10.0.0.1:18080");
}
#[test]
fn rejects_a_non_numeric_http_port_in_the_dotted_form() {
let err = to_grpc_address("host:abc.18080").unwrap_err();
assert!(
matches!(err, AddressError::InvalidHttpPort { .. }),
"{err:?}"
);
assert!(err.to_string().contains("invalid http port"), "{err}");
}
#[test]
fn rejects_a_non_numeric_grpc_port_in_the_dotted_form() {
let err = to_grpc_address("host:8080.xyz").unwrap_err();
assert!(
matches!(err, AddressError::InvalidGrpcPort { .. }),
"{err:?}"
);
assert!(err.to_string().contains("invalid grpc port"), "{err}");
}
#[test]
fn rejects_an_implicit_port_that_leaves_the_tcp_range() {
let err = to_grpc_address("127.0.0.1:60000").unwrap_err();
assert!(
matches!(err, AddressError::ImplicitGrpcPortOutOfRange(60000)),
"{err:?}"
);
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test]
fn the_messages_are_the_volume_servers_wording_verbatim() {
// parse_grpc_address hands these straight to callers that put them in a
// Status or an io::Error, so the whole string is the contract, not just
// the substring the older tests match on. Only the two variants whose
// text is entirely ours are pinned exactly; the other three end in a
// std ParseIntError message, which is std's to reword.
assert_eq!(
to_grpc_address("127.0.0.1:60000").unwrap_err().to_string(),
"implicit grpc port out of range: 60000 + 10000 = 70000"
);
assert_eq!(
to_grpc_address("hostname").unwrap_err().to_string(),
"cannot parse address: hostname"
);
}
#[test]
fn rejects_an_address_without_a_port() {
for source in ["hostname", "no-colon", "localhost"] {
let err = to_grpc_address(source).unwrap_err();
assert!(matches!(err, AddressError::MissingPort(_)), "{err:?}");
assert!(err.to_string().contains("cannot parse"), "{err}");
}
}
// ---- the worker's cases --------------------------------------------
#[test]
fn derives_the_grpc_port() {
assert_eq!(
to_grpc_address("localhost:23646").unwrap(),
"localhost:33646"
);
assert_eq!(
to_grpc_address("127.0.0.1:9333").unwrap(),
"127.0.0.1:19333"
);
}
#[test]
fn honours_an_explicit_grpc_port() {
assert_eq!(
to_grpc_address("localhost:23646.33999").unwrap(),
"localhost:33999"
);
}
#[test]
fn rejects_what_it_cannot_parse() {
let err = to_grpc_address("localhost:notaport").unwrap_err();
assert!(matches!(err, AddressError::InvalidPort { .. }), "{err:?}");
assert!(err.to_string().contains("invalid port"), "{err}");
}
// ---- IPv6, which only the worker's copy handled --------------------
#[test]
fn brackets_ipv6_literals() {
assert_eq!(to_grpc_address("::1:23646").unwrap(), "[::1]:33646");
assert_eq!(to_grpc_address("::1:9333").unwrap(), "[::1]:19333");
assert_eq!(
to_grpc_address("fe80::1:9333.19333").unwrap(),
"[fe80::1]:19333"
);
}
#[test]
fn leaves_an_already_bracketed_literal_alone() {
assert_eq!(to_grpc_address("[::1]:9333").unwrap(), "[::1]:19333");
assert_eq!(to_grpc_address("[::1]:9333.19333").unwrap(), "[::1]:19333");
}
#[test]
fn join_host_port_brackets_only_unbracketed_literals() {
assert_eq!(join_host_port("127.0.0.1", 19333), "127.0.0.1:19333");
assert_eq!(join_host_port("localhost", 19333), "localhost:19333");
assert_eq!(join_host_port("::1", 19333), "[::1]:19333");
assert_eq!(join_host_port("[::1]", 19333), "[::1]:19333");
}
// ---- the offset itself ---------------------------------------------
#[test]
fn the_offset_is_the_seaweedfs_convention() {
assert_eq!(GRPC_PORT_OFFSET, 10000);
}
}
-11
View File
@@ -1,11 +0,0 @@
//! Helpers the SeaweedFS Rust volume server and the Rust plugin workers both need.
//!
//! `seaweed-volume` and `seaweed-worker` are separate cargo trees with separate
//! lockfiles and no root manifest, so anything both of them need was, until this
//! crate existed, written twice. The two things in here are the ones where a
//! second copy is a correctness risk rather than a typing cost: the HTTP↔gRPC
//! address rule, which two copies had already drifted on, and the process-wide
//! rustls provider, which only works if every binary installs the same one.
pub mod address;
pub mod tls;
-28
View File
@@ -1,28 +0,0 @@
//! The process-wide rustls crypto provider.
//!
//! Both binaries link aws-lc-rs and ring transitively — in the volume server
//! through the AWS SDK and reqwest, in the lance worker through lance's `aws`
//! backend and reqwest — so rustls cannot auto-select a provider and tonic's
//! client TLS panics on first use. Each binary has to pin one, and it has to be
//! the same one, which is why the choice lives here rather than in either tree.
use rustls::crypto::aws_lc_rs;
/// Pin rustls's process-wide default provider to aws-lc-rs, matching the
/// volume server's TLS config. Idempotent: the first call wins and every
/// later one is a no-op, so callers do not have to coordinate.
pub fn install_default_crypto_provider() {
let _ = aws_lc_rs::default_provider().install_default();
}
#[cfg(test)]
mod tests {
use super::install_default_crypto_provider;
#[test]
fn installing_is_idempotent_and_leaves_a_default_behind() {
install_default_crypto_provider();
install_default_crypto_provider();
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
}
}
+129 -123
View File
@@ -503,7 +503,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower",
"tower 0.5.3",
"tracing",
]
@@ -628,13 +628,13 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.9"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
@@ -648,13 +648,14 @@ dependencies = [
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -662,17 +663,19 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.6"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
@@ -1651,13 +1654,19 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap",
"indexmap 2.13.1",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1851,7 +1860,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -2018,6 +2027,16 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.13.1"
@@ -2231,9 +2250,9 @@ dependencies = [
[[package]]
name = "matchit"
version = "0.8.4"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "md-5"
@@ -2600,18 +2619,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset 0.4.2",
"indexmap",
"indexmap 2.13.1",
]
[[package]]
name = "petgraph"
version = "0.8.3"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.13.1",
]
[[package]]
@@ -2818,12 +2836,12 @@ dependencies = [
[[package]]
name = "prost"
version = "0.14.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive 0.14.4",
"prost-derive 0.13.5",
]
[[package]]
@@ -2849,20 +2867,19 @@ dependencies = [
[[package]]
name = "prost-build"
version = "0.14.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools 0.14.0",
"log",
"multimap",
"petgraph 0.8.3",
"once_cell",
"petgraph 0.7.1",
"prettyplease",
"prost 0.14.4",
"prost-types 0.14.4",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"prost 0.13.5",
"prost-types 0.13.5",
"regex",
"syn",
"tempfile",
@@ -2883,9 +2900,9 @@ dependencies = [
[[package]]
name = "prost-derive"
version = "0.14.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -2905,11 +2922,11 @@ dependencies = [
[[package]]
name = "prost-types"
version = "0.14.4"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost 0.14.4",
"prost 0.13.5",
]
[[package]]
@@ -2976,26 +2993,6 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.11.0",
"memchr",
"unicase",
]
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "pxfm"
version = "0.1.28"
@@ -3021,7 +3018,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -3059,7 +3056,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -3265,8 +3262,8 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower 0.5.3",
"tower-http 0.6.8",
"tower-service",
"url",
"wasm-bindgen",
@@ -3487,13 +3484,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seaweed-common"
version = "0.1.0"
dependencies = [
"rustls",
]
[[package]]
name = "sec1"
version = "0.3.0"
@@ -3729,6 +3719,16 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -3990,7 +3990,7 @@ dependencies = [
"parking_lot 0.12.5",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -4077,7 +4077,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"indexmap 2.13.1",
"serde",
"serde_spanned",
"toml_datetime",
@@ -4093,10 +4093,11 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.14.6"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
dependencies = [
"async-stream",
"async-trait",
"axum",
"base64",
@@ -4110,12 +4111,13 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"socket2",
"sync_wrapper",
"prost 0.13.5",
"rustls-pemfile",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
@@ -4123,55 +4125,49 @@ dependencies = [
[[package]]
name = "tonic-build"
version = "0.14.6"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build 0.13.5",
"prost-types 0.13.5",
"quote",
"syn",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost 0.14.4",
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build 0.14.4",
"prost-types 0.14.4",
"quote",
"syn",
"tempfile",
"tonic-build",
]
[[package]]
name = "tonic-reflection"
version = "0.14.6"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7"
checksum = "878d81f52e7fcfd80026b7fdb6a9b578b3c3653ba987f87f0dce4b64043cba27"
dependencies = [
"prost 0.14.4",
"prost-types 0.14.4",
"prost 0.13.5",
"prost-types 0.13.5",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.7",
"slab",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -4182,12 +4178,26 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.11.0",
"bytes",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
@@ -4206,10 +4216,9 @@ dependencies = [
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -4486,7 +4495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"indexmap 2.13.1",
"wasm-encoder",
"wasmparser",
]
@@ -4512,7 +4521,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.13.1",
"semver",
]
@@ -4581,8 +4590,8 @@ dependencies = [
"parking_lot 0.12.5",
"pprof",
"prometheus",
"prost 0.14.4",
"prost-types 0.14.4",
"prost 0.13.5",
"prost-types 0.13.5",
"protoc-bin-vendored",
"rand 0.10.2",
"redb",
@@ -4591,7 +4600,6 @@ dependencies = [
"rustls",
"rustls-pemfile",
"rusty-leveldb",
"seaweed-common",
"serde",
"serde_json",
"serde_urlencoded",
@@ -4604,15 +4612,13 @@ dependencies = [
"tokio-stream",
"toml",
"tonic",
"tonic-prost",
"tonic-prost-build",
"tonic-build",
"tonic-reflection",
"tower",
"tower-http",
"tower 0.4.13",
"tower-http 0.5.2",
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
"x509-parser",
"xxhash-rust",
]
@@ -4959,7 +4965,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"indexmap 2.13.1",
"prettyplease",
"syn",
"wasm-metadata",
@@ -4990,7 +4996,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"indexmap",
"indexmap 2.13.1",
"log",
"serde",
"serde_derive",
@@ -5009,7 +5015,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"indexmap 2.13.1",
"log",
"semver",
"serde",
+11 -21
View File
@@ -24,37 +24,32 @@ default = ["5bytes"]
redb-experimental-cursor = ["redb/experimental_cursor"]
[lints.clippy]
# Every RPC path returns tonic::Status (176 bytes). Boxing it would change
# every handler signature for no gain, so the large-Err lint is off.
result_large_err = "allow"
# Protobuf message literals keep `..Default::default()` on purpose: it is
# what lets a proto gain a field without touching every constructor.
needless_update = "allow"
# Every `unsafe` block states its precondition, right above the block.
undocumented_unsafe_blocks = "warn"
[dependencies]
# Helpers the Rust plugin workers (seaweed-worker) need as well. A path
# dependency because the two trees are separate cargo workspaces with no
# common root manifest.
seaweed-common = { path = "../seaweed-common" }
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["net"] }
tokio-io-timeout = "1"
# gRPC + protobuf
tonic = { version = "0.14", features = ["tls-aws-lc"] }
tonic-prost = "0.14"
tonic-reflection = "0.14"
prost = "0.14"
prost-types = "0.14"
tonic = { version = "0.12", features = ["tls"] }
tonic-reflection = "0.12"
prost = "0.13"
prost-types = "0.13"
# HTTP server
axum = { version = "0.8", features = ["multipart"] }
axum = { version = "0.7", features = ["multipart"] }
http-body = "1"
hyper = { version = "1", features = ["full"] }
hyper-util = { version = "0.1", features = ["tokio", "service", "server-auto", "http1", "http2"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
# CLI
clap = { version = "4", features = ["derive"] }
@@ -150,16 +145,11 @@ aws-types = "1"
[target.'cfg(unix)'.dependencies]
pprof = { version = "0.15", features = ["prost-codec"] }
# GetDiskFreeSpaceExW for per-path disk capacity on Windows (0.61.2 already
# in the tree via tempfile/mio, so this unifies rather than adding a version).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
[dev-dependencies]
tempfile = "3"
[build-dependencies]
tonic-prost-build = "0.14"
tonic-build = "0.12"
# Ships protoc with the build so neither CI nor a developer needs a system
# install, and so the version is pinned rather than whatever the platform's
# package manager happens to carry.
+1 -1
View File
@@ -12,7 +12,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR")?);
tonic_prost_build::configure()
tonic_build::configure()
.build_server(true)
.build_client(true)
// filer.proto uses proto3 optional, which protoc rejects without this
+204 -146
View File
@@ -1,31 +1,9 @@
use clap::Parser;
use std::ffi::OsString;
use std::net::UdpSocket;
use std::path::{Path, PathBuf};
use crate::security::tls::TlsPolicy;
/// How this module reads the environment.
///
/// Production passes [`process_env`]; tests pass a map. Injecting the lookup
/// is what lets the tests cover `HOME` and the `WEED_*` overrides without
/// `std::env::set_var`, which is `unsafe` in Rust 2024 because it races with
/// every other thread in the process — and the lib test binary runs tokio
/// runtimes, TLS stacks and AWS clients that read the environment lazily on
/// threads no test mutex can reach.
type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<OsString>;
/// The real process environment, as an [`EnvLookup`].
fn process_env(key: &str) -> Option<OsString> {
std::env::var_os(key)
}
/// `std::env::var`'s view of an [`EnvLookup`]: unset and non-UTF-8 both read
/// as absent, exactly as `std::env::var(key).ok()` does.
fn env_string(env: EnvLookup<'_>, key: &str) -> Option<String> {
env(key).and_then(|value| value.into_string().ok())
}
/// SeaweedFS Volume Server (Rust implementation)
///
/// Start a volume server to provide storage spaces.
@@ -356,7 +334,7 @@ pub fn parse_cli() -> VolumeServerConfig {
let normalized = normalize_args_vec(args);
let merged = merge_options_file(normalized);
let cli = Cli::parse_from(merged);
resolve_config_with_env(cli, &process_env)
resolve_config(cli)
}
/// Find `-options`/`--options` in args, parse the referenced file, and inject
@@ -606,7 +584,7 @@ fn parse_volume_tags(tags_arg: &str, folder_count: usize) -> Vec<Vec<String>> {
folder_tags
}
fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
fn resolve_config(cli: Cli) -> VolumeServerConfig {
// Backward compatibility: --mserver overrides --master
let master_string = if !cli.mserver.is_empty() {
&cli.mserver
@@ -761,7 +739,7 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
};
// Parse security config from TOML file
let sec = parse_security_config_with_env(&cli.security_file, env);
let sec = parse_security_config(&cli.security_file);
// Parse whitelist: merge CLI --whiteList with guard.white_list from security.toml
let mut white_list: Vec<String> = cli
@@ -837,8 +815,9 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
grpc_allowed_wildcard_domain: sec.grpc_allowed_wildcard_domain,
grpc_volume_allowed_common_names: sec.grpc_volume_allowed_common_names,
tls_policy: sec.tls_policy,
enable_write_queue: env_string(env, "SEAWEED_WRITE_QUEUE")
.is_some_and(|v| v == "1" || v == "true"),
enable_write_queue: std::env::var("SEAWEED_WRITE_QUEUE")
.map(|v| v == "1" || v == "true")
.unwrap_or(false),
security_file: cli.security_file,
allow_untrusted_remote_endpoints: cli.allow_untrusted_remote_endpoints,
}
@@ -916,13 +895,9 @@ const SECURITY_CONFIG_FILE_NAME: &str = "security.toml";
/// allowed_commonNames = "volume-a.internal,volume-b.internal"
/// ```
pub fn parse_security_config(path: &str) -> SecurityConfig {
parse_security_config_with_env(path, &process_env)
}
fn parse_security_config_with_env(path: &str, env: EnvLookup<'_>) -> SecurityConfig {
let Some(config_path) = resolve_security_config_path(path, env) else {
let Some(config_path) = resolve_security_config_path(path) else {
let mut cfg = SecurityConfig::default();
apply_env_overrides(&mut cfg, env);
apply_env_overrides(&mut cfg);
return cfg;
};
@@ -930,7 +905,7 @@ fn parse_security_config_with_env(path: &str, env: EnvLookup<'_>) -> SecurityCon
Ok(c) => c,
Err(_) => {
let mut cfg = SecurityConfig::default();
apply_env_overrides(&mut cfg, env);
apply_env_overrides(&mut cfg);
return cfg;
}
};
@@ -1012,9 +987,7 @@ fn parse_security_config_with_env(path: &str, env: EnvLookup<'_>) -> SecurityCon
},
Section::JwtSigning => match key {
"key" => cfg.jwt_signing_key = value.as_bytes().to_vec(),
"expires_after_seconds" => {
cfg.jwt_signing_expires = value.parse().unwrap_or(10)
}
"expires_after_seconds" => cfg.jwt_signing_expires = value.parse().unwrap_or(10),
_ => {}
},
Section::HttpsClient => match key {
@@ -1086,19 +1059,19 @@ fn parse_security_config_with_env(path: &str, env: EnvLookup<'_>) -> SecurityCon
// Override with WEED_ environment variables (matches Go's Viper convention:
// prefix WEED_, uppercase, replace . with _).
// e.g. WEED_JWT_SIGNING_KEY overrides [jwt.signing] key
apply_env_overrides(&mut cfg, env);
apply_env_overrides(&mut cfg);
cfg
}
fn resolve_security_config_path(path: &str, env: EnvLookup<'_>) -> Option<PathBuf> {
fn resolve_security_config_path(path: &str) -> Option<PathBuf> {
if !path.is_empty() {
return Some(PathBuf::from(path));
}
default_security_config_candidates(
std::env::current_dir().ok().as_deref(),
home_dir_from_env(env).as_deref(),
home_dir_from_env().as_deref(),
)
.into_iter()
.find(|candidate| candidate.is_file())
@@ -1120,12 +1093,12 @@ fn default_security_config_candidates(
candidates
}
fn home_dir_from_env(env: EnvLookup<'_>) -> Option<PathBuf> {
env("HOME")
fn home_dir_from_env() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| {
env("USERPROFILE")
std::env::var_os("USERPROFILE")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
})
@@ -1133,80 +1106,80 @@ fn home_dir_from_env(env: EnvLookup<'_>) -> Option<PathBuf> {
/// Apply WEED_ environment variable overrides to a SecurityConfig.
/// Matches Go's Viper convention: WEED_ prefix, uppercase, dots replaced with underscores.
fn apply_env_overrides(cfg: &mut SecurityConfig, env: EnvLookup<'_>) {
if let Some(v) = env_string(env, "WEED_JWT_SIGNING_KEY") {
fn apply_env_overrides(cfg: &mut SecurityConfig) {
if let Ok(v) = std::env::var("WEED_JWT_SIGNING_KEY") {
cfg.jwt_signing_key = v.into_bytes();
}
if let Some(v) = env_string(env, "WEED_JWT_SIGNING_EXPIRES_AFTER_SECONDS") {
if let Ok(v) = std::env::var("WEED_JWT_SIGNING_EXPIRES_AFTER_SECONDS") {
cfg.jwt_signing_expires = v.parse().unwrap_or(cfg.jwt_signing_expires);
}
if let Some(v) = env_string(env, "WEED_JWT_SIGNING_READ_KEY") {
if let Ok(v) = std::env::var("WEED_JWT_SIGNING_READ_KEY") {
cfg.jwt_read_signing_key = v.into_bytes();
}
if let Some(v) = env_string(env, "WEED_JWT_SIGNING_READ_EXPIRES_AFTER_SECONDS") {
if let Ok(v) = std::env::var("WEED_JWT_SIGNING_READ_EXPIRES_AFTER_SECONDS") {
cfg.jwt_read_signing_expires = v.parse().unwrap_or(cfg.jwt_read_signing_expires);
}
if let Some(v) = env_string(env, "WEED_HTTPS_VOLUME_CERT") {
if let Ok(v) = std::env::var("WEED_HTTPS_VOLUME_CERT") {
cfg.https_cert_file = v;
}
if let Some(v) = env_string(env, "WEED_HTTPS_VOLUME_KEY") {
if let Ok(v) = std::env::var("WEED_HTTPS_VOLUME_KEY") {
cfg.https_key_file = v;
}
if let Some(v) = env_string(env, "WEED_HTTPS_VOLUME_CA") {
if let Ok(v) = std::env::var("WEED_HTTPS_VOLUME_CA") {
cfg.https_ca_file = v;
}
if let Some(v) = env_string(env, "WEED_HTTPS_CLIENT_ENABLED") {
if let Ok(v) = std::env::var("WEED_HTTPS_CLIENT_ENABLED") {
cfg.https_client_enabled = v == "true" || v == "1";
}
if let Some(v) = env_string(env, "WEED_HTTPS_CLIENT_CERT") {
if let Ok(v) = std::env::var("WEED_HTTPS_CLIENT_CERT") {
cfg.https_client_cert_file = v;
}
if let Some(v) = env_string(env, "WEED_HTTPS_CLIENT_KEY") {
if let Ok(v) = std::env::var("WEED_HTTPS_CLIENT_KEY") {
cfg.https_client_key_file = v;
}
if let Some(v) = env_string(env, "WEED_HTTPS_CLIENT_CA") {
if let Ok(v) = std::env::var("WEED_HTTPS_CLIENT_CA") {
cfg.https_client_ca_file = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_VOLUME_CERT") {
if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_CERT") {
cfg.grpc_cert_file = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_VOLUME_KEY") {
if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_KEY") {
cfg.grpc_key_file = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_VOLUME_CLIENT_CERT") {
if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_CLIENT_CERT") {
cfg.grpc_client_cert_file = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_VOLUME_CLIENT_KEY") {
if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_CLIENT_KEY") {
cfg.grpc_client_key_file = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_CA") {
if let Ok(v) = std::env::var("WEED_GRPC_CA") {
cfg.grpc_ca_file = v;
} else if let Some(v) = env_string(env, "WEED_GRPC_VOLUME_CA") {
} else if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_CA") {
cfg.grpc_ca_file = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_ALLOWED_WILDCARD_DOMAIN") {
if let Ok(v) = std::env::var("WEED_GRPC_ALLOWED_WILDCARD_DOMAIN") {
cfg.grpc_allowed_wildcard_domain = v;
}
if let Some(v) = env_string(env, "WEED_GRPC_VOLUME_ALLOWED_COMMONNAMES") {
if let Ok(v) = std::env::var("WEED_GRPC_VOLUME_ALLOWED_COMMONNAMES") {
cfg.grpc_volume_allowed_common_names = v.split(',').map(|name| name.to_string()).collect();
}
if let Some(v) = env_string(env, "WEED_TLS_MIN_VERSION") {
if let Ok(v) = std::env::var("WEED_TLS_MIN_VERSION") {
cfg.tls_policy.min_version = v;
}
if let Some(v) = env_string(env, "WEED_TLS_MAX_VERSION") {
if let Ok(v) = std::env::var("WEED_TLS_MAX_VERSION") {
cfg.tls_policy.max_version = v;
}
if let Some(v) = env_string(env, "WEED_TLS_CIPHER_SUITES") {
if let Ok(v) = std::env::var("WEED_TLS_CIPHER_SUITES") {
cfg.tls_policy.cipher_suites = v;
}
if let Some(v) = env_string(env, "WEED_GUARD_WHITE_LIST") {
if let Ok(v) = std::env::var("WEED_GUARD_WHITE_LIST") {
cfg.guard_white_list = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Some(v) = env_string(env, "WEED_ACCESS_UI") {
if let Ok(v) = std::env::var("WEED_ACCESS_UI") {
cfg.access_ui = v == "true" || v == "1";
}
}
@@ -1227,21 +1200,41 @@ fn detect_host_address() -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard, OnceLock};
/// Serialises the tests that move the process working directory against
/// the ones that read it. `set_current_dir` is safe, but it is still
/// process-global: without this, a `parse_security_config("")` in one test
/// would search another test's temporary directory.
///
/// Nothing here guards the environment any more — tests inject an
/// [`EnvLookup`] instead of mutating the real one.
fn process_state_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
// SAFETY (all env mutation in this module): `set_var`/`remove_var` are
// unsafe as of Rust 2024 because they race with concurrent readers in
// other threads. Every test that reaches these helpers holds
// `process_state_lock()` for the duration, so only one test at a time
// touches the environment and none observes another's edit.
fn with_temp_env_var<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
let previous = std::env::var_os(key);
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
f();
restore_env_var(key, previous);
}
fn restore_env_var(key: &str, value: Option<OsString>) {
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
fn with_temp_current_dir<F: FnOnce()>(dir: &Path, f: F) {
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(dir).unwrap();
@@ -1249,14 +1242,51 @@ mod tests {
std::env::set_current_dir(previous).unwrap();
}
/// The whole environment a test wants the config layer to see, ready to
/// pass as `&env`. `&|_| None` is an environment with nothing set at all.
fn fake_env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
let map: HashMap<String, OsString> = pairs
fn with_cleared_security_env<F: FnOnce()>(f: F) {
const KEYS: &[&str] = &[
"WEED_JWT_SIGNING_KEY",
"WEED_JWT_SIGNING_EXPIRES_AFTER_SECONDS",
"WEED_JWT_SIGNING_READ_KEY",
"WEED_JWT_SIGNING_READ_EXPIRES_AFTER_SECONDS",
"WEED_HTTPS_VOLUME_CERT",
"WEED_HTTPS_VOLUME_KEY",
"WEED_HTTPS_VOLUME_CA",
"WEED_HTTPS_CLIENT_ENABLED",
"WEED_HTTPS_CLIENT_CERT",
"WEED_HTTPS_CLIENT_KEY",
"WEED_HTTPS_CLIENT_CA",
"WEED_GRPC_VOLUME_CERT",
"WEED_GRPC_VOLUME_KEY",
"WEED_GRPC_VOLUME_CLIENT_CERT",
"WEED_GRPC_VOLUME_CLIENT_KEY",
"WEED_GRPC_CA",
"WEED_GRPC_VOLUME_CA",
"WEED_GRPC_ALLOWED_WILDCARD_DOMAIN",
"WEED_GRPC_VOLUME_ALLOWED_COMMONNAMES",
"WEED_TLS_MIN_VERSION",
"WEED_TLS_MAX_VERSION",
"WEED_TLS_CIPHER_SUITES",
"WEED_GUARD_WHITE_LIST",
"WEED_ACCESS_UI",
];
let previous: Vec<(&str, Option<OsString>)> = KEYS
.iter()
.map(|(key, value)| ((*key).to_string(), OsString::from(*value)))
.map(|key| (*key, std::env::var_os(key)))
.collect();
move |key| map.get(key).cloned()
for key in KEYS {
// SAFETY: as above — the caller holds `process_state_lock()`.
unsafe {
std::env::remove_var(key);
}
}
f();
for (key, value) in previous {
restore_env_var(key, value);
}
}
#[test]
@@ -1393,17 +1423,17 @@ mod tests {
#[test]
fn test_resolve_config_defaults_dir_to_platform_temp_dir() {
// With no --securityFile, the security-config search reads the process
// working directory, so this has to hold the lock against
// `with_temp_current_dir`. The environment is injected, not shared.
// resolve_config reads HOME/USERPROFILE and the WEED_* set, so it has to
// hold the same lock the mutation helpers take — a concurrent set_var
// during this read is exactly what makes those calls unsafe.
let _guard = process_state_lock();
let cfg = resolve_config_with_env(Cli::parse_from(["bin"]), &|_| None);
let cfg = resolve_config(Cli::parse_from(["bin"]));
assert_eq!(cfg.folders, vec![default_volume_dir()]);
}
#[test]
fn test_resolve_config_index_accepts_redb_and_leveldb_aliases() {
// As above: the security-config search reads the working directory.
// As above: resolve_config reads the environment.
let _guard = process_state_lock();
let pairs = [
("memory", NeedleMapKind::InMemory),
@@ -1415,14 +1445,14 @@ mod tests {
("leveldbLarge", NeedleMapKind::RedbLarge),
];
for (input, expected) in pairs {
let cfg =
resolve_config_with_env(Cli::parse_from(["bin", "--index", input]), &|_| None);
let cfg = resolve_config(Cli::parse_from(["bin", "--index", input]));
assert_eq!(cfg.index_type, expected, "input={}", input);
}
}
#[test]
fn test_parse_security_config_access_ui() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1436,9 +1466,11 @@ ui = true
)
.unwrap();
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &|_| None);
assert_eq!(cfg.jwt_signing_key, b"secret");
assert!(cfg.access_ui);
with_cleared_security_env(|| {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert_eq!(cfg.jwt_signing_key, b"secret");
assert!(cfg.access_ui);
});
}
#[test]
@@ -1455,8 +1487,10 @@ key = "cwd-secret"
.unwrap();
with_temp_current_dir(tmp.path(), || {
let cfg = parse_security_config_with_env("", &|_| None);
assert_eq!(cfg.jwt_signing_key, b"cwd-secret");
with_temp_env_var("WEED_JWT_SIGNING_KEY", None, || {
let cfg = parse_security_config("");
assert_eq!(cfg.jwt_signing_key, b"cwd-secret");
});
});
}
@@ -1476,15 +1510,19 @@ key = "home-secret"
)
.unwrap();
let env = fake_env(&[("HOME", home_dir.path().to_str().unwrap())]);
with_temp_current_dir(current_dir.path(), || {
let cfg = parse_security_config_with_env("", &env);
assert_eq!(cfg.jwt_signing_key, b"home-secret");
with_temp_env_var("WEED_JWT_SIGNING_KEY", None, || {
with_temp_env_var("HOME", Some(home_dir.path().to_str().unwrap()), || {
let cfg = parse_security_config("");
assert_eq!(cfg.jwt_signing_key, b"home-secret");
});
});
});
}
#[test]
fn test_parse_security_config_uses_grpc_root_ca() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1499,14 +1537,17 @@ key = "/etc/seaweedfs/volume-key.pem"
)
.unwrap();
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &|_| None);
assert_eq!(cfg.grpc_ca_file, "/etc/seaweedfs/grpc-ca.pem");
assert_eq!(cfg.grpc_cert_file, "/etc/seaweedfs/volume-cert.pem");
assert_eq!(cfg.grpc_key_file, "/etc/seaweedfs/volume-key.pem");
with_cleared_security_env(|| {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert_eq!(cfg.grpc_ca_file, "/etc/seaweedfs/grpc-ca.pem");
assert_eq!(cfg.grpc_cert_file, "/etc/seaweedfs/volume-cert.pem");
assert_eq!(cfg.grpc_key_file, "/etc/seaweedfs/volume-key.pem");
});
}
#[test]
fn test_parse_security_config_uses_grpc_volume_client_cert() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1520,19 +1561,22 @@ client_key = "/etc/seaweedfs/volume-client-key.pem"
)
.unwrap();
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &|_| None);
assert_eq!(
cfg.grpc_client_cert_file,
"/etc/seaweedfs/volume-client-cert.pem"
);
assert_eq!(
cfg.grpc_client_key_file,
"/etc/seaweedfs/volume-client-key.pem"
);
with_cleared_security_env(|| {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert_eq!(
cfg.grpc_client_cert_file,
"/etc/seaweedfs/volume-client-cert.pem"
);
assert_eq!(
cfg.grpc_client_key_file,
"/etc/seaweedfs/volume-client-key.pem"
);
});
}
#[test]
fn test_parse_security_config_uses_grpc_peer_name_policy() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1546,19 +1590,22 @@ allowed_commonNames = "volume-a.internal,volume-b.internal"
)
.unwrap();
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &|_| None);
assert_eq!(cfg.grpc_allowed_wildcard_domain, ".example.com");
assert_eq!(
cfg.grpc_volume_allowed_common_names,
vec![
String::from("volume-a.internal"),
String::from("volume-b.internal")
]
);
with_cleared_security_env(|| {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert_eq!(cfg.grpc_allowed_wildcard_domain, ".example.com");
assert_eq!(
cfg.grpc_volume_allowed_common_names,
vec![
String::from("volume-a.internal"),
String::from("volume-b.internal")
]
);
});
}
#[test]
fn test_parse_security_config_uses_https_client_settings() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1572,15 +1619,18 @@ ca = "/etc/seaweedfs/client-ca.pem"
)
.unwrap();
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &|_| None);
assert!(cfg.https_client_enabled);
assert_eq!(cfg.https_client_cert_file, "/etc/seaweedfs/client-cert.pem");
assert_eq!(cfg.https_client_key_file, "/etc/seaweedfs/client-key.pem");
assert_eq!(cfg.https_client_ca_file, "/etc/seaweedfs/client-ca.pem");
with_cleared_security_env(|| {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert!(cfg.https_client_enabled);
assert_eq!(cfg.https_client_cert_file, "/etc/seaweedfs/client-cert.pem");
assert_eq!(cfg.https_client_key_file, "/etc/seaweedfs/client-key.pem");
assert_eq!(cfg.https_client_ca_file, "/etc/seaweedfs/client-ca.pem");
});
}
#[test]
fn test_parse_security_config_uses_tls_policy_settings() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1593,13 +1643,15 @@ cipher_suites = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
)
.unwrap();
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &|_| None);
assert_eq!(cfg.tls_policy.min_version, "TLS 1.2");
assert_eq!(cfg.tls_policy.max_version, "TLS 1.3");
assert_eq!(
cfg.tls_policy.cipher_suites,
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
);
with_cleared_security_env(|| {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert_eq!(cfg.tls_policy.min_version, "TLS 1.2");
assert_eq!(cfg.tls_policy.max_version, "TLS 1.3");
assert_eq!(
cfg.tls_policy.cipher_suites,
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
);
});
}
#[test]
@@ -1700,15 +1752,16 @@ cipher_suites = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
#[test]
fn test_env_override_jwt_signing_key() {
// No file path, so the search reads the working directory.
let _guard = process_state_lock();
let env = fake_env(&[("WEED_JWT_SIGNING_KEY", "env-secret")]);
let cfg = parse_security_config_with_env("", &env);
assert_eq!(cfg.jwt_signing_key, b"env-secret");
with_temp_env_var("WEED_JWT_SIGNING_KEY", Some("env-secret"), || {
let cfg = parse_security_config("");
assert_eq!(cfg.jwt_signing_key, b"env-secret");
});
}
#[test]
fn test_env_override_takes_precedence_over_file() {
let _guard = process_state_lock();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
@@ -1719,26 +1772,31 @@ key = "file-secret"
)
.unwrap();
let env = fake_env(&[("WEED_JWT_SIGNING_KEY", "env-secret")]);
let cfg = parse_security_config_with_env(tmp.path().to_str().unwrap(), &env);
assert_eq!(cfg.jwt_signing_key, b"env-secret");
with_temp_env_var("WEED_JWT_SIGNING_KEY", Some("env-secret"), || {
let cfg = parse_security_config(tmp.path().to_str().unwrap());
assert_eq!(cfg.jwt_signing_key, b"env-secret");
});
}
#[test]
fn test_env_override_guard_white_list() {
// No file path, so the search reads the working directory.
let _guard = process_state_lock();
let env = fake_env(&[("WEED_GUARD_WHITE_LIST", "10.0.0.0/8, 192.168.1.0/24")]);
let cfg = parse_security_config_with_env("", &env);
assert_eq!(cfg.guard_white_list, vec!["10.0.0.0/8", "192.168.1.0/24"]);
with_temp_env_var(
"WEED_GUARD_WHITE_LIST",
Some("10.0.0.0/8, 192.168.1.0/24"),
|| {
let cfg = parse_security_config("");
assert_eq!(cfg.guard_white_list, vec!["10.0.0.0/8", "192.168.1.0/24"]);
},
);
}
#[test]
fn test_env_override_access_ui() {
// No file path, so the search reads the working directory.
let _guard = process_state_lock();
let env = fake_env(&[("WEED_ACCESS_UI", "true")]);
let cfg = parse_security_config_with_env("", &env);
assert!(cfg.access_ui);
with_temp_env_var("WEED_ACCESS_UI", Some("true"), || {
let cfg = parse_security_config("");
assert!(cfg.access_ui);
});
}
}
+35 -38
View File
@@ -6,22 +6,19 @@ use seaweed_volume::config::{self, VolumeServerConfig};
use seaweed_volume::metrics;
use seaweed_volume::pb::volume_server_pb::volume_server_server::VolumeServerServer;
use seaweed_volume::security::tls::{
GrpcClientAuthPolicy, TlsPolicy, build_rustls_server_config,
build_rustls_server_config_with_grpc_client_auth, install_default_crypto_provider,
build_rustls_server_config, build_rustls_server_config_with_grpc_client_auth,
install_default_crypto_provider, GrpcClientAuthPolicy, TlsPolicy,
};
use seaweed_volume::security::{Guard, SigningKey};
#[cfg(unix)]
use seaweed_volume::server::debug::build_debug_router;
use seaweed_volume::server::grpc_client::{
GRPC_INITIAL_WINDOW_SIZE, GRPC_KEEPALIVE_INTERVAL, GRPC_KEEPALIVE_TIMEOUT,
GRPC_MAX_MESSAGE_SIZE, load_outgoing_grpc_tls,
};
use seaweed_volume::server::grpc_client::load_outgoing_grpc_tls;
use seaweed_volume::server::grpc_server::VolumeGrpcService;
#[cfg(unix)]
use seaweed_volume::server::profiling::CpuProfileSession;
use seaweed_volume::server::request_id::GrpcRequestIdLayer;
use seaweed_volume::server::volume_server::{
RuntimeMetricsConfig, VolumeServerState, build_metrics_router,
build_metrics_router, RuntimeMetricsConfig, VolumeServerState,
};
use seaweed_volume::server::write_queue::WriteQueue;
use seaweed_volume::storage::store::Store;
@@ -34,10 +31,10 @@ type CpuProfileParam = Option<CpuProfileSession>;
#[cfg(not(unix))]
type CpuProfileParam = Option<()>;
// The two settings that only make sense for the inbound server. The rest of
// this server's HTTP/2 tuning — keepalive, window sizes, message size — is
// imported from `server::grpc_client` above, which is also what the outgoing
// clients dial with, so the two directions cannot drift apart.
const GRPC_MAX_MESSAGE_SIZE: usize = 1 << 30;
const GRPC_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
const GRPC_KEEPALIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
const GRPC_INITIAL_WINDOW_SIZE: u32 = 16 * 1024 * 1024;
const GRPC_MAX_HEADER_LIST_SIZE: u32 = 8 * 1024 * 1024;
const GRPC_MAX_CONCURRENT_STREAMS: u32 = 1000;
@@ -346,6 +343,9 @@ async fn run(
pre_stop_seconds: config.pre_stop_seconds,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
s3_tier_registry: std::sync::RwLock::new(
seaweed_volume::remote_storage::s3_tier::S3TierRegistry::new(),
),
read_mode: config.read_mode,
allow_untrusted_remote_endpoints: config.allow_untrusted_remote_endpoints,
master_url,
@@ -671,7 +671,8 @@ async fn run(
})
.await
} else {
let incoming = tokio_stream::wrappers::TcpListenerStream::new(grpc_listener);
let incoming =
tokio_stream::wrappers::TcpListenerStream::new(grpc_listener);
info!("gRPC server listening on {}", grpc_local_addr);
build_grpc_server_builder()
.layer(GrpcRequestIdLayer)
@@ -1057,17 +1058,15 @@ mod tests {
#[test]
fn test_grpc_server_tls_returns_none_when_files_are_missing() {
assert!(
build_grpc_server_tls_acceptor(
"/missing/server.crt",
"/missing/server.key",
"/missing/ca.crt",
&TlsPolicy::default(),
"",
&[],
)
.is_none()
);
assert!(build_grpc_server_tls_acceptor(
"/missing/server.crt",
"/missing/server.key",
"/missing/ca.crt",
&TlsPolicy::default(),
"",
&[],
)
.is_none());
}
#[test]
@@ -1089,21 +1088,19 @@ mod tests {
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
);
assert!(
build_grpc_server_tls_acceptor(
&cert,
&key,
&ca,
&TlsPolicy {
min_version: "TLS 1.0".to_string(),
max_version: "TLS 1.1".to_string(),
cipher_suites: String::new(),
},
"",
&[],
)
.is_none()
);
assert!(build_grpc_server_tls_acceptor(
&cert,
&key,
&ca,
&TlsPolicy {
min_version: "TLS 1.0".to_string(),
max_version: "TLS 1.1".to_string(),
cipher_suites: String::new(),
},
"",
&[],
)
.is_none());
}
#[test]
+2 -5
View File
@@ -349,7 +349,6 @@ pub const DOWNLOAD_LIMIT_COND: &str = "downloadLimitCondition";
pub const UPLOAD_LIMIT_COND: &str = "uploadLimitCondition";
pub const READ_PROXY_REQ: &str = "readProxyRequest";
pub const READ_REDIRECT_REQ: &str = "readRedirectRequest";
pub const READ_DELETED_NEEDLE: &str = "readDeletedNeedle";
pub const EMPTY_READ_PROXY_LOC: &str = "emptyReadProxyLocaction";
pub const FAILED_READ_PROXY_REQ: &str = "failedReadProxyRequest";
@@ -526,7 +525,7 @@ pub async fn push_metrics_once(
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, routing::put};
use axum::{routing::put, Router};
use std::sync::{Arc, Mutex};
#[test]
@@ -598,9 +597,7 @@ mod tests {
register_metrics();
VOLUME_GAUGE.with_label_values(&["pics", "volume"]).set(2.0);
VOLUME_GAUGE
.with_label_values(&["pics", "ec_shards"])
.set(3.0);
VOLUME_GAUGE.with_label_values(&["pics", "ec_shards"]).set(3.0);
READ_ONLY_VOLUME_GAUGE
.with_label_values(&["pics", "volume"])
.set(1.0);
@@ -119,11 +119,7 @@ pub fn check_blocked_ip(endpoint: &str, ip: IpAddr) -> Result<(), String> {
/// reachable for callers whose target legitimately sits on an internal network
/// (peer volume servers), while still blocking loopback, link-local (IMDS) and
/// unspecified. Mirrors Go's `checkBlockedIPPolicy`.
pub fn check_blocked_ip_policy(
endpoint: &str,
ip: IpAddr,
allow_private: bool,
) -> Result<(), String> {
pub fn check_blocked_ip_policy(endpoint: &str, ip: IpAddr, allow_private: bool) -> Result<(), String> {
// Normalize IPv4-mapped IPv6 (`::ffff:a.b.c.d`) to its IPv4 form so the
// IPv4 deny rules apply. The OS routes these to the embedded IPv4 address,
// so without this `::ffff:127.0.0.1` / `::ffff:169.254.169.254` would slip
@@ -235,7 +231,7 @@ fn precheck_endpoint(endpoint: &str) -> Result<HostCheck, String> {
return Err(format!(
"remote endpoint {:?} has a malformed IPv6 host",
endpoint
));
))
}
}
} else {
@@ -311,10 +307,7 @@ pub async fn validate_replica_target(target: &str) -> Result<(), String> {
return Err("replica target is empty".to_string());
}
if trimmed.contains("://") || trimmed.contains(['/', '?', '#', '@', '\\']) {
return Err(format!(
"replica target {:?} must be a bare host:port",
target
));
return Err(format!("replica target {:?} must be a bare host:port", target));
}
// Require an explicit host:port, handling `[IPv6]:port`. A bracketless IPv6
@@ -323,22 +316,12 @@ pub async fn validate_replica_target(target: &str) -> Result<(), String> {
let host = if let Some(rest) = trimmed.strip_prefix('[') {
match rest.split_once(']') {
Some((h, port)) if port.starts_with(':') && port.len() > 1 => h,
_ => {
return Err(format!(
"replica target {:?} must be a bare host:port",
target
));
}
_ => return Err(format!("replica target {:?} must be a bare host:port", target)),
}
} else {
match trimmed.rsplit_once(':') {
Some((h, port)) if !port.is_empty() && !h.contains(':') => h,
_ => {
return Err(format!(
"replica target {:?} must be a bare host:port",
target
));
}
_ => return Err(format!("replica target {:?} must be a bare host:port", target)),
}
};
@@ -357,10 +340,7 @@ pub async fn validate_replica_target(target: &str) -> Result<(), String> {
let addrs = resolve_host(host).await?;
if addrs.is_empty() {
return Err(format!(
"resolve replica target host {:?}: no addresses",
host
));
return Err(format!("resolve replica target host {:?}: no addresses", host));
}
for ip in addrs {
check_blocked_ip_policy(target, ip, true)?;
@@ -368,56 +348,6 @@ pub async fn validate_replica_target(target: &str) -> Result<(), String> {
Ok(())
}
/// Resolve `host`, re-apply the replica deny list (private peers allowed) to
/// every resolved address, and connect to the first one that passes -- the
/// connect-time twin of [`validate_replica_target`], so a hostname whose DNS
/// answer flips to a blocked address after the up-front check is still refused.
/// Mirrors Go's `guardedDialerPolicy` with allowPrivate=true.
pub async fn guarded_tcp_connect(
host: &str,
port: u16,
endpoint: &str,
) -> std::io::Result<tokio::net::TcpStream> {
use std::io::{Error, ErrorKind};
let denied = |e: String| Error::new(ErrorKind::PermissionDenied, e);
if is_blocked_imds_host(&host.to_ascii_lowercase()) {
return Err(denied(format!(
"remote endpoint {:?} targets instance metadata service",
endpoint
)));
}
if let Ok(ip) = host.parse::<IpAddr>() {
check_blocked_ip_policy(endpoint, ip, true).map_err(denied)?;
return tokio::net::TcpStream::connect((ip, port)).await;
}
let lookup = tokio::net::lookup_host((host.to_string(), port));
let addrs = tokio::time::timeout(std::time::Duration::from_secs(2), lookup)
.await
.map_err(|_| {
Error::new(
ErrorKind::TimedOut,
format!("resolve remote endpoint host {:?}: timed out", host),
)
})??;
let mut first_block_err: Option<String> = None;
for addr in addrs {
if let Err(e) = check_blocked_ip_policy(endpoint, addr.ip(), true) {
if first_block_err.is_none() {
first_block_err = Some(e);
}
continue;
}
return tokio::net::TcpStream::connect(addr).await;
}
Err(denied(first_block_err.unwrap_or_else(|| {
format!("resolve remote endpoint host {:?}: no addresses", host)
})))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -448,30 +378,22 @@ mod tests {
#[test]
fn rejects_empty_and_bad_scheme() {
assert!(precheck_endpoint("").unwrap_err().contains("empty"));
assert!(
precheck_endpoint("ftp://example.com/")
.unwrap_err()
.contains("http or https")
);
assert!(
precheck_endpoint("example.com/")
.unwrap_err()
.contains("http or https")
);
assert!(precheck_endpoint("ftp://example.com/")
.unwrap_err()
.contains("http or https"));
assert!(precheck_endpoint("example.com/")
.unwrap_err()
.contains("http or https"));
}
#[test]
fn rejects_imds_hostnames() {
assert!(
precheck_endpoint("http://metadata.google.internal/")
.unwrap_err()
.contains("metadata service")
);
assert!(
precheck_endpoint("http://metadata/")
.unwrap_err()
.contains("metadata service")
);
assert!(precheck_endpoint("http://metadata.google.internal/")
.unwrap_err()
.contains("metadata service"));
assert!(precheck_endpoint("http://metadata/")
.unwrap_err()
.contains("metadata service"));
}
#[test]
@@ -491,41 +413,27 @@ mod tests {
#[test]
fn check_blocked_ip_matches_resolved_categories() {
// Mirror Go's "host resolves to X" cases at the address level.
assert!(
check_blocked_ip("e", ip("127.0.0.1"))
.unwrap_err()
.contains("loopback")
);
assert!(
check_blocked_ip("e", ip("169.254.10.20"))
.unwrap_err()
.contains("link-local")
);
assert!(
check_blocked_ip("e", ip("10.1.2.3"))
.unwrap_err()
.contains("private")
);
assert!(
check_blocked_ip("e", ip("172.20.0.5"))
.unwrap_err()
.contains("private")
);
assert!(
check_blocked_ip("e", ip("192.168.1.1"))
.unwrap_err()
.contains("private")
);
assert!(
check_blocked_ip("e", ip("100.64.0.42"))
.unwrap_err()
.contains("CGNAT")
);
assert!(
check_blocked_ip("e", ip("fc00::1"))
.unwrap_err()
.contains("private")
);
assert!(check_blocked_ip("e", ip("127.0.0.1"))
.unwrap_err()
.contains("loopback"));
assert!(check_blocked_ip("e", ip("169.254.10.20"))
.unwrap_err()
.contains("link-local"));
assert!(check_blocked_ip("e", ip("10.1.2.3"))
.unwrap_err()
.contains("private"));
assert!(check_blocked_ip("e", ip("172.20.0.5"))
.unwrap_err()
.contains("private"));
assert!(check_blocked_ip("e", ip("192.168.1.1"))
.unwrap_err()
.contains("private"));
assert!(check_blocked_ip("e", ip("100.64.0.42"))
.unwrap_err()
.contains("CGNAT"));
assert!(check_blocked_ip("e", ip("fc00::1"))
.unwrap_err()
.contains("private"));
assert!(check_blocked_ip("e", ip("52.216.10.10")).is_ok());
assert!(check_blocked_ip("e", ip("2606:4700:4700::1111")).is_ok());
}
@@ -568,45 +476,33 @@ mod tests {
assert!(check_blocked_ip("e", ip("2001::f7f7:f7f7")).is_ok());
assert!(check_blocked_ip("e", ip("::808:808")).is_ok());
// Bracketed transition literal via the full endpoint path.
assert!(
precheck_endpoint("http://[64:ff9b::a9fe:a9fe]/")
.unwrap_err()
.contains("metadata")
);
assert!(precheck_endpoint("http://[64:ff9b::a9fe:a9fe]/")
.unwrap_err()
.contains("metadata"));
}
#[test]
fn rejects_ipv4_mapped_ipv6() {
// IPv4-mapped IPv6 must be unmapped so the IPv4 rules catch it.
assert!(
check_blocked_ip("e", ip("::ffff:127.0.0.1"))
.unwrap_err()
.contains("loopback")
);
assert!(
check_blocked_ip("e", ip("::ffff:169.254.169.254"))
.unwrap_err()
.contains("metadata")
);
assert!(
check_blocked_ip("e", ip("::ffff:10.0.0.1"))
.unwrap_err()
.contains("private")
);
assert!(check_blocked_ip("e", ip("::ffff:127.0.0.1"))
.unwrap_err()
.contains("loopback"));
assert!(check_blocked_ip("e", ip("::ffff:169.254.169.254"))
.unwrap_err()
.contains("metadata"));
assert!(check_blocked_ip("e", ip("::ffff:10.0.0.1"))
.unwrap_err()
.contains("private"));
// A mapped public address still passes, and genuine IPv6 loopback is
// still caught by the V6 path.
assert!(check_blocked_ip("e", ip("::ffff:52.216.10.10")).is_ok());
assert!(
check_blocked_ip("e", ip("::1"))
.unwrap_err()
.contains("loopback")
);
assert!(check_blocked_ip("e", ip("::1"))
.unwrap_err()
.contains("loopback"));
// Bracketed mapped literal via the full endpoint path.
assert!(
precheck_endpoint("http://[::ffff:127.0.0.1]/")
.unwrap_err()
.contains("loopback")
);
assert!(precheck_endpoint("http://[::ffff:127.0.0.1]/")
.unwrap_err()
.contains("loopback"));
}
#[test]
@@ -616,86 +512,60 @@ mod tests {
assert!(check_blocked_ip_policy("e", ip("192.168.1.5"), true).is_ok());
assert!(check_blocked_ip_policy("e", ip("100.64.0.42"), true).is_ok());
// Loopback / IMDS / unspecified stay blocked even when private is allowed.
assert!(
check_blocked_ip_policy("e", ip("127.0.0.1"), true)
.unwrap_err()
.contains("loopback")
);
assert!(
check_blocked_ip_policy("e", ip("169.254.169.254"), true)
.unwrap_err()
.contains("metadata")
);
assert!(
check_blocked_ip_policy("e", ip("0.0.0.0"), true)
.unwrap_err()
.contains("unspecified")
);
assert!(check_blocked_ip_policy("e", ip("127.0.0.1"), true)
.unwrap_err()
.contains("loopback"));
assert!(check_blocked_ip_policy("e", ip("169.254.169.254"), true)
.unwrap_err()
.contains("metadata"));
assert!(check_blocked_ip_policy("e", ip("0.0.0.0"), true)
.unwrap_err()
.contains("unspecified"));
}
#[tokio::test]
async fn validate_replica_target_rejects_and_allows() {
// A path plus a trailing ?a= would otherwise swallow ?type=replicate.
assert!(
validate_replica_target("127.0.0.1:7000/status/x/?a=")
.await
.unwrap_err()
.contains("bare host:port")
);
assert!(
validate_replica_target("http://10.0.0.7:8080")
.await
.unwrap_err()
.contains("bare host:port")
);
assert!(
validate_replica_target("user@10.0.0.7:8080")
.await
.unwrap_err()
.contains("bare host:port")
);
assert!(
validate_replica_target("10.0.0.7")
.await
.unwrap_err()
.contains("bare host:port")
);
assert!(
validate_replica_target("peer.example.com")
.await
.unwrap_err()
.contains("bare host:port")
);
assert!(
validate_replica_target("127.0.0.1:8080")
.await
.unwrap_err()
.contains("loopback")
);
assert!(
validate_replica_target("[::1]:8080")
.await
.unwrap_err()
.contains("loopback")
);
assert!(
validate_replica_target("169.254.169.254:80")
.await
.unwrap_err()
.contains("metadata")
);
assert!(
validate_replica_target("metadata:80")
.await
.unwrap_err()
.contains("metadata")
);
assert!(
validate_replica_target("")
.await
.unwrap_err()
.contains("empty")
);
assert!(validate_replica_target("127.0.0.1:7000/status/x/?a=")
.await
.unwrap_err()
.contains("bare host:port"));
assert!(validate_replica_target("http://10.0.0.7:8080")
.await
.unwrap_err()
.contains("bare host:port"));
assert!(validate_replica_target("user@10.0.0.7:8080")
.await
.unwrap_err()
.contains("bare host:port"));
assert!(validate_replica_target("10.0.0.7")
.await
.unwrap_err()
.contains("bare host:port"));
assert!(validate_replica_target("peer.example.com")
.await
.unwrap_err()
.contains("bare host:port"));
assert!(validate_replica_target("127.0.0.1:8080")
.await
.unwrap_err()
.contains("loopback"));
assert!(validate_replica_target("[::1]:8080")
.await
.unwrap_err()
.contains("loopback"));
assert!(validate_replica_target("169.254.169.254:80")
.await
.unwrap_err()
.contains("metadata"));
assert!(validate_replica_target("metadata:80")
.await
.unwrap_err()
.contains("metadata"));
assert!(validate_replica_target("")
.await
.unwrap_err()
.contains("empty"));
// Legitimate peer volume servers on private networks pass.
assert!(validate_replica_target("10.0.0.7:8080").await.is_ok());
assert!(validate_replica_target("192.168.1.5:8080").await.is_ok());
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod endpoint_guard;
pub mod s3;
pub mod s3_tier;
pub use endpoint_guard::{guarded_tcp_connect, validate_remote_endpoint, validate_replica_target};
pub use endpoint_guard::{validate_remote_endpoint, validate_replica_target};
use crate::pb::remote_pb::{RemoteConf, RemoteStorageLocation};
+1 -1
View File
@@ -2,9 +2,9 @@
//!
//! Works with AWS S3, MinIO, SeaweedFS S3, and all S3-compatible providers.
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client;
use super::{RemoteEntry, RemoteStorageClient, RemoteStorageError};
use crate::pb::remote_pb::{RemoteConf, RemoteStorageLocation};
+1 -1
View File
@@ -7,9 +7,9 @@ use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, OnceLock, RwLock};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use aws_sdk_s3::Client;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::sync::Semaphore;
+4 -6
View File
@@ -10,7 +10,7 @@ use std::collections::HashSet;
use std::net::IpAddr;
use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
// ============================================================================
@@ -481,11 +481,9 @@ mod tests {
let token = gen_jwt(&key, 3600, "3,01637037d6").unwrap();
// Correct file ID
assert!(
guard
.check_jwt_for_file(Some(&token), "3,01637037d6", true)
.is_ok()
);
assert!(guard
.check_jwt_for_file(Some(&token), "3,01637037d6", true)
.is_ok());
// Wrong file ID
let err = guard.check_jwt_for_file(Some(&token), "4,deadbeef", true);
+7 -8
View File
@@ -3,12 +3,12 @@ use std::fmt;
use std::sync::Arc;
use rustls::client::danger::HandshakeSignatureValid;
use rustls::crypto::CryptoProvider;
use rustls::crypto::aws_lc_rs;
use rustls::crypto::CryptoProvider;
use rustls::pki_types::UnixTime;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::WebPkiClientVerifier;
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
use rustls::server::WebPkiClientVerifier;
use rustls::{
CipherSuite, DigitallySignedStruct, DistinguishedName, RootCertStore, ServerConfig,
SignatureScheme, SupportedCipherSuite, SupportedProtocolVersion,
@@ -120,11 +120,10 @@ impl ClientCertVerifier for CommonNameVerifier {
// aws-lc-rs and ring both get linked transitively, so rustls can't auto-select
// a provider and tonic's client TLS panics on first use. Pin the default to
// aws-lc-rs, matching the server config. Idempotent. The body lives in
// seaweed-common so this binary and the Rust plugin workers cannot end up
// installing different providers; re-exported here so callers keep their
// import path.
pub use seaweed_common::tls::install_default_crypto_provider;
// aws-lc-rs, matching the server config. Idempotent.
pub fn install_default_crypto_provider() {
let _ = aws_lc_rs::default_provider().install_default();
}
pub fn build_rustls_server_config(
cert_path: &str,
@@ -377,7 +376,7 @@ fn go_tls_version_for_supported(version: &SupportedProtocolVersion) -> GoTlsVers
#[cfg(test)]
mod tests {
use super::{TlsPolicy, build_supported_versions, common_name_is_allowed, parse_cipher_suites};
use super::{build_supported_versions, common_name_is_allowed, parse_cipher_suites, TlsPolicy};
use rustls::crypto::aws_lc_rs;
use std::collections::HashSet;
+2 -2
View File
@@ -1,9 +1,9 @@
use axum::Router;
use axum::body::Body;
use axum::extract::Query;
use axum::http::{StatusCode, header};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::Router;
use pprof::protos::Message;
use serde::Deserialize;
+45 -352
View File
@@ -1,42 +1,16 @@
//! Construction of the volume server's *outgoing* gRPC clients: TLS material,
//! endpoint tuning, dial bounds, and the three client constructors every call
//! site goes through.
//!
//! The keepalive, window-size and message-size constants below are shared with
//! the *inbound* server built in `main.rs`, which imports them from here rather
//! than declaring its own. Changing one therefore changes both directions at
//! once, which is deliberate: a volume server talks to its peers with the same
//! HTTP/2 settings it offers them.
use std::error::Error;
use std::fmt;
use std::time::Duration;
use hyper::http::Uri;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
use tonic::{Request, Status};
use crate::config::VolumeServerConfig;
use crate::pb::filer_pb::seaweed_filer_client::SeaweedFilerClient;
use crate::pb::master_pb::seaweed_client::SeaweedClient;
use crate::pb::volume_server_pb::volume_server_client::VolumeServerClient;
use crate::server::request_id::outgoing_request_id_interceptor;
pub const GRPC_MAX_MESSAGE_SIZE: usize = 1 << 30;
pub const GRPC_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(60);
pub const GRPC_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
pub const GRPC_INITIAL_WINDOW_SIZE: u32 = 16 * 1024 * 1024;
/// Bound on the TCP connect of every outgoing dial. `build_grpc_endpoint` is
/// private and `connect_channel` is the only way out of this module, so every
/// call site picks this up whether it thinks about timeouts or not.
///
/// It bounds the TCP handshake only — tonic hands it to
/// `HttpConnector::set_connect_timeout`. A peer that completes the handshake
/// and then stalls in the TLS or HTTP/2 exchange is not covered; callers that
/// need that bound wrap the whole dial (see `connect_ping_target`).
const GRPC_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const GRPC_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(60);
const GRPC_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
const GRPC_INITIAL_WINDOW_SIZE: u32 = 16 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct OutgoingGrpcTlsConfig {
@@ -66,9 +40,7 @@ pub fn load_outgoing_grpc_tls(
(&config.grpc_client_cert_file, &config.grpc_client_key_file)
} else {
if !config.grpc_client_cert_file.is_empty() || !config.grpc_client_key_file.is_empty() {
tracing::warn!(
"grpc.volume.client_cert and grpc.volume.client_key must both be set, falling back to grpc.volume.cert and grpc.volume.key"
);
tracing::warn!("grpc.volume.client_cert and grpc.volume.client_key must both be set, falling back to grpc.volume.cert and grpc.volume.key");
}
(&config.grpc_cert_file, &config.grpc_key_file)
};
@@ -107,7 +79,7 @@ pub fn grpc_endpoint_uri(grpc_host_port: &str, tls: Option<&OutgoingGrpcTlsConfi
format!("{}://{}", scheme, grpc_host_port)
}
fn build_grpc_endpoint(
pub fn build_grpc_endpoint(
grpc_host_port: &str,
tls: Option<&OutgoingGrpcTlsConfig>,
) -> Result<Endpoint, GrpcClientError> {
@@ -143,184 +115,6 @@ fn build_grpc_endpoint(
Ok(endpoint)
}
/// Connect `endpoint` through a connector that re-validates every resolved
/// address at connect time (Go's `guardedDialerPolicy` mirror), pinning a
/// validated copy/tail source against DNS rebinding. `allow_untrusted`
/// preserves the plain connect for operators that opted out.
pub async fn connect_guarded(
endpoint: Endpoint,
target: &str,
allow_untrusted: bool,
) -> Result<Channel, GrpcClientError> {
if allow_untrusted {
return endpoint
.connect()
.await
.map_err(|e| GrpcClientError(format!("connect {} failed: {}", target, e)));
}
let target_owned = target.to_string();
let connector = tower::service_fn(move |uri: Uri| {
let target = target_owned.clone();
async move {
let host = uri.host().unwrap_or_default().to_string();
let port = uri.port_u16().unwrap_or(80);
crate::remote_storage::guarded_tcp_connect(&host, port, &target)
.await
.map(hyper_util::rt::TokioIo::new)
}
});
endpoint
.connect_with_connector(connector)
.await
.map_err(|e| GrpcClientError(format!("connect {} failed: {}", target, e)))
}
/// How a dial is bounded.
///
/// `connect_timeout` is handed to the TCP connector. `request_timeout` becomes
/// [`Endpoint::timeout`], which tonic installs as a `GrpcTimeout` layer in
/// front of *every* request the resulting channel carries — it is not a
/// property of one call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GrpcDialOptions {
/// Bound on establishing the connection to the peer.
pub connect_timeout: Duration,
/// Deadline applied to each RPC on the channel, or `None` to leave them
/// unbounded.
pub request_timeout: Option<Duration>,
}
impl GrpcDialOptions {
/// A short request/response call: connect within 5 s, answer within 10 s.
pub fn unary() -> Self {
Self {
connect_timeout: GRPC_CONNECT_TIMEOUT,
request_timeout: Some(Duration::from_secs(10)),
}
}
/// A call the peer may take a while to answer: connect within 5 s, answer
/// within 30 s.
pub fn long() -> Self {
Self {
connect_timeout: GRPC_CONNECT_TIMEOUT,
request_timeout: Some(Duration::from_secs(30)),
}
}
/// A bounded connect with no deadline on the RPCs themselves.
///
/// `request_timeout` must stay `None` here. [`Endpoint::timeout`] is not a
/// transfer budget: tonic layers it as a `GrpcTimeout` around the
/// response future, which resolves when the server's *first response
/// headers* arrive, so it bounds how long the peer may take to start
/// answering — per request, for every request the channel carries. A 10 s
/// value picked to suit one short call would therefore also be the header
/// deadline for the `VolumeCopy` that shares the dial, and a busy source
/// that takes longer than that to open its file would lose the whole copy.
/// `VolumeCopy`, `VolumeTailSender` and `VolumeEcShardsCopy` have never
/// carried one.
pub fn stream() -> Self {
Self {
connect_timeout: GRPC_CONNECT_TIMEOUT,
request_timeout: None,
}
}
}
/// Dial a peer and return a connected channel.
///
/// The error carries only the transport failure: every caller already wraps it
/// with the address and the operation it was attempting.
pub async fn connect_channel(
grpc_host_port: &str,
tls: Option<&OutgoingGrpcTlsConfig>,
opts: GrpcDialOptions,
) -> Result<Channel, GrpcClientError> {
let mut endpoint =
build_grpc_endpoint(grpc_host_port, tls)?.connect_timeout(opts.connect_timeout);
if let Some(request_timeout) = opts.request_timeout {
endpoint = endpoint.timeout(request_timeout);
}
endpoint
.connect()
.await
.map_err(|e| GrpcClientError(e.to_string()))
}
/// Dial a copy/tail source and return a connected channel, re-validating every
/// resolved address at connect time.
///
/// The guarded equivalent of [`connect_channel`]: same `opts` bounds, but the
/// dial goes through [`connect_guarded`] so a source address that passed
/// validation cannot be re-pointed by DNS between the check and the connect.
/// The bounds are applied to the endpoint *before* delegating, so the
/// `allow_untrusted` opt-out is timed too.
///
/// `target` is the caller-facing source address (the unparsed
/// `"ip:port.grpcPort"` form), which is what the guard pins against; the error
/// carries only the transport failure, as every caller already wraps it with
/// the address and the operation it was attempting.
pub async fn connect_channel_guarded(
grpc_host_port: &str,
target: &str,
tls: Option<&OutgoingGrpcTlsConfig>,
opts: GrpcDialOptions,
allow_untrusted: bool,
) -> Result<Channel, GrpcClientError> {
let mut endpoint =
build_grpc_endpoint(grpc_host_port, tls)?.connect_timeout(opts.connect_timeout);
if let Some(request_timeout) = opts.request_timeout {
endpoint = endpoint.timeout(request_timeout);
}
connect_guarded(endpoint, target, allow_untrusted).await
}
/// The outgoing request-id interceptor as a concrete type, so the client
/// aliases below can name it.
pub type RequestIdInterceptor = fn(Request<()>) -> Result<Request<()>, Status>;
/// A volume-server client with the request-id interceptor attached.
pub type VolumeServerGrpcClient =
VolumeServerClient<InterceptedService<Channel, RequestIdInterceptor>>;
/// A master client with the request-id interceptor attached.
pub type MasterGrpcClient = SeaweedClient<InterceptedService<Channel, RequestIdInterceptor>>;
/// A filer client with the request-id interceptor attached.
pub type FilerGrpcClient = SeaweedFilerClient<InterceptedService<Channel, RequestIdInterceptor>>;
/// Wrap a connected channel in a volume-server client that forwards the
/// current request id and lifts both message-size limits.
pub fn volume_server_client(channel: Channel) -> VolumeServerGrpcClient {
VolumeServerClient::with_interceptor(
channel,
outgoing_request_id_interceptor as RequestIdInterceptor,
)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE)
}
/// Wrap a connected channel in a master client that forwards the current
/// request id and lifts both message-size limits.
pub fn master_client(channel: Channel) -> MasterGrpcClient {
SeaweedClient::with_interceptor(
channel,
outgoing_request_id_interceptor as RequestIdInterceptor,
)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE)
}
/// Wrap a connected channel in a filer client that forwards the current
/// request id and lifts both message-size limits.
pub fn filer_client(channel: Channel) -> FilerGrpcClient {
SeaweedFilerClient::with_interceptor(
channel,
outgoing_request_id_interceptor as RequestIdInterceptor,
)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE)
}
/// Parse a SeaweedFS server address (`"ip:port.grpcPort"` or
/// `"ip:port"`) into the `host:grpcPort` form `build_grpc_endpoint`
/// expects. With the trailing `.grpcPort` segment, that segment IS
@@ -330,27 +124,53 @@ pub fn filer_client(channel: Channel) -> FilerGrpcClient {
/// Shared between `grpc_server.rs` and the distributed-EC-read path
/// in `store_ec.rs` — keep this as the single source of truth so the
/// HTTP↔gRPC port translation can't drift between callers.
///
/// The rule itself lives in `seaweed_common::address`, which the Rust
/// plugin workers share; this wrapper only flattens the typed error
/// back to the `String` its callers already handle. Unbracketed IPv6
/// literals come back bracketed, which this copy used to get wrong.
pub fn parse_grpc_address(source: &str) -> Result<String, String> {
seaweed_common::address::to_grpc_address(source).map_err(|e| e.to_string())
let colon_idx = source
.rfind(':')
.ok_or_else(|| format!("cannot parse address: {}", source))?;
let host = &source[..colon_idx];
let port_part = &source[colon_idx + 1..];
if let Some(dot_idx) = port_part.rfind('.') {
// Format: "ip:port.grpcPort". Validate BOTH ports as u16
// so a malformed HTTP port (e.g. `host:abc.18080`) is
// rejected here rather than tripping a downstream
// `build_grpc_endpoint` URI parse failure with a less
// useful error.
let http_port = &port_part[..dot_idx];
let grpc_port = &port_part[dot_idx + 1..];
http_port
.parse::<u16>()
.map_err(|e| format!("invalid http port {:?}: {}", http_port, e))?;
grpc_port
.parse::<u16>()
.map_err(|e| format!("invalid grpc port {:?}: {}", grpc_port, e))?;
return Ok(format!("{}:{}", host, grpc_port));
}
// Format: "ip:port" → grpc = port + 10000. Reject inputs whose
// implicit grpc port would overflow the TCP port range (e.g.
// `host:60000` produces 70000 — invalid). Without this check
// the cast silently wraps and the endpoint call later fails
// with an opaque connection error.
let port: u16 = port_part
.parse()
.map_err(|e| format!("invalid port {:?}: {}", port_part, e))?;
let grpc_port = port as u32 + 10000;
if grpc_port > u16::MAX as u32 {
return Err(format!(
"implicit grpc port out of range: {} + 10000 = {}",
port, grpc_port
));
}
Ok(format!("{}:{}", host, grpc_port))
}
#[cfg(test)]
mod tests {
use super::{
GrpcDialOptions, build_grpc_endpoint, connect_channel, grpc_endpoint_uri,
load_outgoing_grpc_tls, volume_server_client,
};
use super::{build_grpc_endpoint, grpc_endpoint_uri, load_outgoing_grpc_tls};
use crate::config::{NeedleMapKind, ReadMode, VolumeServerConfig};
use crate::pb::volume_server_pb;
use crate::security::tls::TlsPolicy;
use crate::server::request_id::scope_request_id;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBPDCB76ADAgECAhRuRPQgeAu43BT/M7EfAWSdapVdYDAFBgMrZXAwFDESMBAG\nA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcwNTE2MTUyOVoXDTM2MDcwMjE2MTUyOVow\nFDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAr/3bNIFI+8V32oCiY6y+\nXRFmZpdNQ2g//VtRkT+nQg+jUzBRMB0GA1UdDgQWBBTsy9tLf1zPiXCQfgci6zNi\ndEzRSjAfBgNVHSMEGDAWgBTsy9tLf1zPiXCQfgci6zNidEzRSjAPBgNVHRMBAf8E\nBTADAQH/MAUGAytlcANBAIvsdw0IbvOBBkb9cd7BfMJfIP9pQQrAL03pCRWJFnFh\nSysaLVgFXI4T078IiaM874oO+iB+5vNbWEpc7CkGow4=\n-----END CERTIFICATE-----\n";
const TEST_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHbyn71Kk+Y7KT3sBctit7uZpErpoH6qDbFj6P8qGaZH\n-----END PRIVATE KEY-----\n";
@@ -545,131 +365,4 @@ mod tests {
let err = parse_grpc_address("hostname").unwrap_err();
assert!(err.contains("cannot parse"), "{}", err);
}
#[test]
fn test_parse_grpc_address_brackets_ipv6_literals() {
use super::parse_grpc_address;
// This used to come back as `::1:29333`, which is not a valid
// authority: `build_grpc_endpoint` reads the last colon as the port
// separator and rejects the rest.
assert_eq!(parse_grpc_address("::1:19333").unwrap(), "[::1]:29333");
assert_eq!(parse_grpc_address("::1:9333.19333").unwrap(), "[::1]:19333");
// Already bracketed, so it is left alone.
assert_eq!(parse_grpc_address("[::1]:9333").unwrap(), "[::1]:19333");
}
#[test]
fn test_build_grpc_endpoint_accepts_an_ipv6_master_address() {
use super::parse_grpc_address;
let endpoint = build_grpc_endpoint(&parse_grpc_address("::1:9333").unwrap(), None).unwrap();
assert_eq!(endpoint.uri().port_u16(), Some(19333));
}
/// A minimal HTTP/2 server that records the gRPC request headers it is
/// sent and answers every call with a trailers-only `unimplemented`. It is
/// enough to prove what a helper-built client puts on the wire, without
/// standing up the whole `VolumeServer` service behind a tonic server.
async fn serve_header_capture() -> (u16, Arc<Mutex<Option<String>>>) {
use hyper::service::service_fn;
use hyper_util::rt::{TokioExecutor, TokioIo};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let captured = Arc::clone(&seen);
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let captured = Arc::clone(&captured);
tokio::spawn(async move {
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(
TokioIo::new(stream),
service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let captured = Arc::clone(&captured);
async move {
let value = req
.headers()
.get("x-amz-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
*captured.lock().unwrap() = value;
Ok::<_, std::convert::Infallible>(
hyper::http::Response::builder()
.status(200)
.header("content-type", "application/grpc")
.header("grpc-status", "12")
.body(tonic::body::Body::empty())
.unwrap(),
)
}
}),
)
.await;
});
}
});
(port, seen)
}
#[tokio::test]
async fn test_helper_built_client_sends_the_scoped_request_id() {
let (port, seen) = serve_header_capture().await;
let channel = connect_channel(
&format!("127.0.0.1:{}", port),
None,
GrpcDialOptions::unary(),
)
.await
.expect("dial the header-capturing server");
let mut client = volume_server_client(channel);
// The interceptor has a request id to forward only inside a scope, so
// the call has to run inside one for this to test anything.
let _ = scope_request_id("REQUEST-ID-ON-THE-WIRE".to_string(), async move {
client
.ping(volume_server_pb::PingRequest {
target: String::new(),
target_type: String::new(),
})
.await
})
.await;
assert_eq!(
seen.lock().unwrap().as_deref(),
Some("REQUEST-ID-ON-THE-WIRE"),
"a client built by volume_server_client must carry the outgoing request id"
);
}
#[test]
fn test_dial_presets_match_the_call_sites_they_replace() {
assert_eq!(
GrpcDialOptions::unary().connect_timeout,
Duration::from_secs(5)
);
assert_eq!(
GrpcDialOptions::unary().request_timeout,
Some(Duration::from_secs(10))
);
assert_eq!(
GrpcDialOptions::long().connect_timeout,
Duration::from_secs(5)
);
assert_eq!(
GrpcDialOptions::long().request_timeout,
Some(Duration::from_secs(30))
);
assert_eq!(
GrpcDialOptions::stream().connect_timeout,
Duration::from_secs(5)
);
assert_eq!(
GrpcDialOptions::stream().request_timeout,
None,
"a streaming dial must not put a per-request deadline on the channel"
);
}
}
File diff suppressed because it is too large Load Diff
+165 -453
View File
@@ -6,18 +6,17 @@
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, Method, Request, StatusCode, header};
use axum::http::{header, HeaderMap, Method, Request, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use super::absolute_display_path;
use super::grpc_client::{GrpcDialOptions, connect_channel, volume_server_client};
use super::volume_server::{VolumeServerState, normalize_outgoing_http_url, to_http_address};
use super::grpc_client::{build_grpc_endpoint, GRPC_MAX_MESSAGE_SIZE};
use super::volume_server::{normalize_outgoing_http_url, to_http_address, VolumeServerState};
use crate::config::ReadMode;
use crate::metrics;
use crate::pb::volume_server_pb;
@@ -280,104 +279,31 @@ impl Drop for StreamingBody {
// URL Parsing
// ============================================================================
/// The pieces a needle URL carries, as Go's `parseURLPath` splits them
/// (`weed/server/common.go:218-249`). Borrowed from the path so the callers
/// that only need one field do not allocate.
///
/// `fid` keeps any `_delta` suffix, exactly like Go: applying the delta is
/// `parse_needle_id_cookie`'s job (Go's `needle.ParsePath`), and the JWT check
/// strips it separately.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct NeedlePath<'a> {
pub vid: &'a str,
pub fid: &'a str,
/// Extension including the dot (`.jpg`), or "" when the URL has none.
pub ext: &'a str,
/// The trailing display name of the `vid/fid/filename.ext` form only.
pub filename: Option<&'a str>,
}
/// Split a needle URL path into volume id, file id, extension and filename.
///
/// Dispatches on the slash count like Go's `parseURLPath`, which is what
/// decides where the extension is taken from:
/// - `/vid/fid/filename.ext` (Go case 3): the filename carries the extension
/// and the fid is left intact.
/// - `/vid/fid.ext` (Go case 2): the extension is split off the fid.
/// - `/vid,fid.ext` (Go's default): the last path segment is split on its last
/// comma, then on its last dot.
///
/// The leading slash is optional, so chunk manifest entries ("3,01637037d6")
/// parse through the same function. Returns `None` for a path with no file id
/// at all — Go's `isVolumeIdOnly` case — since every caller here needs one.
pub(crate) fn parse_needle_path(path: &str) -> Option<NeedlePath<'_>> {
// Go counts one more slash than this because it keeps the leading one.
let trimmed = path.trim_start_matches('/');
match trimmed.matches('/').count() {
2 => {
let mut parts = trimmed.splitn(3, '/');
let vid = parts.next()?;
let fid = parts.next()?;
let filename = parts.next()?;
// Go uses filepath.Ext here, which has no "dot at index 0" guard.
let ext = filename.rfind('.').map_or("", |dot| &filename[dot..]);
Some(NeedlePath {
vid,
fid,
ext,
filename: Some(filename),
})
}
1 => {
let (vid, fid) = trimmed.split_once('/')?;
let (fid, ext) = split_extension(fid);
Some(NeedlePath {
vid,
fid,
ext,
filename: None,
})
}
_ => {
// Go looks only after the last slash, so a deeper path has no
// comma to find and falls out as invalid.
let segment = trimmed.rsplit_once('/').map_or(trimmed, |(_, last)| last);
let comma = segment.rfind(',')?;
let (fid, ext) = split_extension(&segment[comma + 1..]);
Some(NeedlePath {
vid: &segment[..comma],
fid,
ext,
filename: None,
})
}
}
}
/// Split a trailing `.ext` off a file id. Go guards this with `dotIndex > 0`,
/// so a file id that is nothing but an extension keeps it and fails to parse
/// later instead of becoming empty here.
fn split_extension(fid: &str) -> (&str, &str) {
match fid.rfind('.') {
Some(dot) if dot > 0 => (&fid[..dot], &fid[dot..]),
_ => (fid, ""),
}
}
/// Parse volume ID and file ID from URL path.
/// Supports: "vid,fid", "vid/fid", "vid,fid.ext", "vid/fid/filename.ext"
/// Extract the file_id string (e.g., "3,01637037d6") from a URL path for JWT validation.
///
/// Go compares the token's `fid` claim against `vid + "," + fid` for every URL
/// form, after dropping an `_suffix` (`volume_server_handlers.go:361-364`), so
/// the comma form is emitted here even when the request used slashes.
fn extract_file_id(path: &str) -> String {
let Some(parsed) = parse_needle_path(path) else {
return path.trim_start_matches('/').to_string();
};
let fid = match parsed.fid.rfind('_') {
Some(sep) if sep > 0 => &parsed.fid[..sep],
_ => parsed.fid,
};
format!("{},{}", parsed.vid, fid)
let path = path.trim_start_matches('/');
// Strip extension and filename after second slash
if let Some(comma) = path.find(',') {
let after_comma = &path[comma + 1..];
let fid_part = if let Some(slash) = after_comma.find('/') {
&after_comma[..slash]
} else if let Some(dot) = after_comma.rfind('.') {
&after_comma[..dot]
} else {
after_comma
};
// Strip "_suffix" from fid (Go does this for filenames appended with underscore)
let fid_part = if let Some(underscore) = fid_part.rfind('_') {
&fid_part[..underscore]
} else {
fid_part
};
format!("{},{}", &path[..comma], fid_part)
} else {
path.to_string()
}
}
fn streaming_chunk_size(read_buffer_size_bytes: usize, data_size: usize) -> usize {
@@ -388,11 +314,32 @@ fn streaming_chunk_size(read_buffer_size_bytes: usize, data_size: usize) -> usiz
}
fn parse_url_path(path: &str) -> Option<(VolumeId, NeedleId, Cookie)> {
let parsed = parse_needle_path(path)?;
let vid = VolumeId::parse(parsed.vid).ok()?;
// parse_needle_id_cookie applies an `_delta` suffix itself (Go ParsePath).
let path = path.trim_start_matches('/');
// Try "vid,fid" or "vid/fid" or "vid/fid/filename" formats
let (vid_str, fid_part) = if let Some(pos) = path.find(',') {
(&path[..pos], &path[pos + 1..])
} else {
let pos = path.find('/')?;
(&path[..pos], &path[pos + 1..])
};
// For fid part, strip extension from the fid (not from filename)
// "vid,fid.ext" -> fid is before dot
// "vid/fid/filename.ext" -> fid is the part before the second slash
let fid_str = if let Some(slash_pos) = fid_part.find('/') {
// "fid/filename.ext" - fid is before the slash
&fid_part[..slash_pos]
} else if let Some(dot) = fid_part.rfind('.') {
// "fid.ext" - strip extension
&fid_part[..dot]
} else {
fid_part
};
let vid = VolumeId::parse(vid_str).ok()?;
let (needle_id, cookie) =
crate::storage::needle::needle::parse_needle_id_cookie(parsed.fid).ok()?;
crate::storage::needle::needle::parse_needle_id_cookie(fid_str).ok()?;
Some((vid, needle_id, cookie))
}
@@ -541,16 +488,19 @@ async fn batch_delete_file_ids(
}
for (grpc_addr, batch) in server_to_file_ids {
// BatchDelete is unary, but `stream()` is still right: this fan-out has
// no per-request deadline today, and `unary()` would add a 10 s one.
let channel = connect_channel(
&grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::stream(),
)
.await
.map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?;
let mut client = volume_server_client(channel);
let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?;
let channel = endpoint
.connect()
.await
.map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?;
let mut client =
volume_server_pb::volume_server_client::VolumeServerClient::with_interceptor(
channel,
super::request_id::outgoing_request_id_interceptor,
)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let response = client
.batch_delete(volume_server_pb::BatchDeleteRequest {
@@ -723,16 +673,34 @@ fn build_proxy_request_info(
headers: &HeaderMap,
query_string: &str,
) -> Option<ProxyRequestInfo> {
// Go redirects to "vid,fid" built from parseURLPath, so the extension must
// already be off the fid here (volume_server_handlers_read.go:128-137).
let parsed = parse_needle_path(path)?;
let trimmed = path.trim_start_matches('/');
let (vid_str, fid_str) = if let Some(pos) = trimmed.find(',') {
let raw_fid = &trimmed[pos + 1..];
let fid = if let Some(slash) = raw_fid.find('/') {
&raw_fid[..slash]
} else if let Some(dot) = raw_fid.rfind('.') {
&raw_fid[..dot]
} else {
raw_fid
};
(trimmed[..pos].to_string(), fid.to_string())
} else {
let pos = trimmed.find('/')?;
let after = &trimmed[pos + 1..];
let fid_part = if let Some(slash) = after.find('/') {
&after[..slash]
} else {
after
};
(trimmed[..pos].to_string(), fid_part.to_string())
};
Some(ProxyRequestInfo {
original_headers: headers.clone(),
original_query: query_string.to_string(),
path: path.to_string(),
vid_str: parsed.vid.to_string(),
fid_str: parsed.fid.to_string(),
vid_str,
fid_str,
})
}
@@ -1306,6 +1274,7 @@ async fn get_or_head_handler_inner(
&& let Some(resp) = try_expand_chunk_manifest(
&state,
&n,
&headers,
&method,
&path,
&query,
@@ -1351,7 +1320,9 @@ async fn get_or_head_handler_inner(
// H6: Determine Content-Type: filter application/octet-stream, use mime_guess
// For chunk manifests, skip extension-based MIME override — use stored MIME as-is (Go parity)
let content_type = if n.is_chunk_manifest() {
let content_type = if let Some(ref ct) = query.response_content_type {
Some(ct.clone())
} else if n.is_chunk_manifest() {
// Chunk manifests: use stored MIME but filter application/octet-stream (Go L334)
if !n.mime.is_empty() {
let mt = String::from_utf8_lossy(&n.mime).to_string();
@@ -1400,50 +1371,27 @@ async fn get_or_head_handler_inner(
}
}
};
// Every value below can come straight from the query string, so none of
// them may be unwrapped: `?response-cache-control=%0Aevil` decodes to a
// value with a newline, `HeaderValue::from_str` rejects it, and the unwrap
// would panic the connection task. An invalid `response-content-type`
// falls back to the needle MIME rather than dropping Content-Type.
if let Some(hval) = query
.response_content_type
.as_ref()
.and_then(|ct| ct.parse::<header::HeaderValue>().ok())
{
response_headers.insert(header::CONTENT_TYPE, hval);
} else if let Some(ref ct) = content_type
&& let Ok(hval) = ct.parse()
{
response_headers.insert(header::CONTENT_TYPE, hval);
if let Some(ref ct) = content_type {
response_headers.insert(header::CONTENT_TYPE, ct.parse().unwrap());
}
// Cache-Control override from query param
if let Some(ref cc) = query.response_cache_control
&& let Ok(hval) = cc.parse()
{
response_headers.insert(header::CACHE_CONTROL, hval);
if let Some(ref cc) = query.response_cache_control {
response_headers.insert(header::CACHE_CONTROL, cc.parse().unwrap());
}
// S3 response passthrough headers
if let Some(ref ce) = query.response_content_encoding
&& let Ok(hval) = ce.parse()
{
response_headers.insert(header::CONTENT_ENCODING, hval);
if let Some(ref ce) = query.response_content_encoding {
response_headers.insert(header::CONTENT_ENCODING, ce.parse().unwrap());
}
if let Some(ref exp) = query.response_expires
&& let Ok(hval) = exp.parse()
{
response_headers.insert(header::EXPIRES, hval);
if let Some(ref exp) = query.response_expires {
response_headers.insert(header::EXPIRES, exp.parse().unwrap());
}
if let Some(ref cl) = query.response_content_language
&& let Ok(hval) = cl.parse()
{
response_headers.insert("Content-Language", hval);
if let Some(ref cl) = query.response_content_language {
response_headers.insert("Content-Language", cl.parse().unwrap());
}
if let Some(ref cd) = query.response_content_disposition
&& let Ok(hval) = cd.parse()
{
response_headers.insert(header::CONTENT_DISPOSITION, hval);
if let Some(ref cd) = query.response_content_disposition {
response_headers.insert(header::CONTENT_DISPOSITION, cd.parse().unwrap());
}
// Last-Modified
@@ -1558,7 +1506,7 @@ async fn get_or_head_handler_inner(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
)
.into_response();
.into_response()
}
Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes
}
@@ -1584,7 +1532,7 @@ async fn get_or_head_handler_inner(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
)
.into_response();
.into_response()
}
Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes
}
@@ -1863,7 +1811,7 @@ fn handle_range_request_from_source(
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", err),
)
.into_response();
.into_response()
}
};
headers.insert(
@@ -1893,7 +1841,7 @@ fn handle_range_request_from_source(
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", err),
)
.into_response();
.into_response()
}
};
if i == 0 {
@@ -2171,11 +2119,7 @@ pub async fn post_handler(
let (vid, needle_id, cookie) = match parse_url_path(&path) {
Some(parsed) => parsed,
None => {
return json_error_with_query(
StatusCode::BAD_REQUEST,
"invalid URL path",
Some(&query),
);
return json_error_with_query(StatusCode::BAD_REQUEST, "invalid URL path", Some(&query))
}
};
@@ -2785,7 +2729,7 @@ pub async fn delete_handler(
StatusCode::BAD_REQUEST,
"invalid URL path",
Some(&del_query),
);
)
}
};
@@ -2808,23 +2752,15 @@ pub async fn delete_handler(
{
let has_ec = state.store.read().unwrap().has_ec_volume(vid);
if has_ec {
// Step 1: Read the EC needle to get its size and validate cookie.
//
// This must go through the *distributed* reader, as the GET path
// does. The local-only `EcVolume::read_ec_shard_needle` errors
// "ec shard N not available locally" for any interval that lives on
// a peer, so on a standard 10+4 spread every HTTP DELETE of an EC
// needle failed 500 and never appended to `.ecj`. The distributed
// reader does a local-first pass in its snapshot phase, so the
// all-shards-local case costs the same as before.
//
// No store guard is held across this call: the reader takes and
// releases its own, and `RwLockReadGuard` is `!Send`.
let ec_read_result =
crate::server::store_ec::read_ec_shard_needle_distributed(&state, vid, needle_id)
.await;
// Step 1: Read the EC needle to get its size and validate cookie
let ec_read_result = {
let store = state.store.read().unwrap();
store
.find_ec_volume(vid)
.map(|ecv| ecv.read_ec_shard_needle(needle_id))
};
match ec_read_result {
Ok(Some(ec_needle)) => {
Some(Ok(Some(ec_needle))) => {
// Step 2: Validate cookie (Go: cookie != 0 && cookie != n.Cookie)
if cookie.0 != 0 && ec_needle.cookie != cookie {
return json_error_with_query(
@@ -2834,50 +2770,26 @@ pub async fn delete_handler(
);
}
let count = ec_needle.data_size as i64;
// Step 3: Journal the delete on a holder of the needle's
// primary data shard — Go's DeleteEcShardNeedle forwards a
// VolumeEcBlobDelete there rather than journaling locally,
// so exactly one node carries the tombstone and
// delete_count stays consistent across replicas.
match crate::server::store_ec::delete_ec_shard_needle_distributed(
&state, vid, needle_id,
)
.await
// Step 3: Journal the delete
let mut store = state.store.write().unwrap();
if let Some(ecv) = store.find_ec_volume_mut(vid)
&& let Err(e) = ecv.journal_delete(needle_id)
{
Ok(()) => {
let result = DeleteResult { size: count };
return json_response_with_params(
StatusCode::ACCEPTED,
&result,
Some(&del_params),
);
}
// Unmounted between the read and the append, or the
// needle went away in the same window: nothing was
// journalled, so answering 202 would lose the delete
// while reporting success.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
Err(e) => {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
}
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
}
let result = DeleteResult { size: count };
return json_response_with_params(
StatusCode::ACCEPTED,
&result,
Some(&del_params),
);
}
Ok(None) => {
// Needle not in the EC index, or the volume disappeared
// between the `has_ec` check and the snapshot — the
// distributed reader reports both as `Ok(None)`, and both
// mean the same thing to a deleter.
Some(Ok(None)) => {
// Needle not found in EC volume
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
@@ -2885,25 +2797,22 @@ pub async fn delete_handler(
Some(&del_params),
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// A shard or interval the read needed is gone rather than
// merely remote. The GET path answers 404 here; answering
// 500 (as this handler did for every error) told callers to
// retry a delete that can never succeed.
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
Err(e) => {
Some(Err(e)) => {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
}
None => {
// EC volume disappeared between has_ec check and find
let result = DeleteResult { size: 0 };
return json_response_with_params(
StatusCode::NOT_FOUND,
&result,
Some(&del_params),
);
}
}
}
}
@@ -3322,9 +3231,11 @@ struct ChunkInfo {
}
/// Try to expand a chunk manifest needle. Returns None if manifest can't be parsed.
#[expect(clippy::too_many_arguments)]
async fn try_expand_chunk_manifest(
state: &Arc<VolumeServerState>,
n: &Needle,
_headers: &HeaderMap,
method: &Method,
path: &str,
query: &ReadQueryParams,
@@ -3341,7 +3252,7 @@ async fn try_expand_chunk_manifest(
"compressed manifest exceeds decompression limit",
)
.into_response(),
);
)
}
Err(GunzipError::Decode) => return None,
}
@@ -3388,7 +3299,7 @@ async fn try_expand_chunk_manifest(
format!("read chunk {}: {}", chunk.fid, e),
)
.into_response(),
);
)
}
};
let offset = chunk.offset as usize;
@@ -3675,6 +3586,16 @@ async fn read_remote_chunk_needle(
// Helpers
// ============================================================================
fn absolute_display_path(path: &str) -> String {
let p = std::path::Path::new(path);
if p.is_absolute() {
return path.to_string();
}
std::env::current_dir()
.map(|cwd| cwd.join(p).to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string())
}
fn build_disk_statuses(store: &crate::storage::store::Store) -> Vec<serde_json::Value> {
let mut disk_statuses = Vec::new();
for loc in &store.locations {
@@ -3879,8 +3800,8 @@ fn is_compressible_file_type(ext: &str, mtype: &str) -> bool {
/// Try to gzip data. Returns None on error.
fn try_gzip_data(data: &[u8]) -> Option<Vec<u8>> {
use flate2::Compression;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).ok()?;
@@ -3977,161 +3898,6 @@ mod tests {
);
}
/// Every URL form the volume server accepts, against what Go's
/// `parseURLPath` returns for it (`weed/server/common.go:218-249`):
/// the `vid/fid/filename` form leaves the fid alone and takes the
/// extension off the filename, the other two take it off the fid, and
/// none of them touch an `_delta` suffix.
#[test]
fn test_parse_needle_path_every_form() {
let cases: &[(&str, &str, &str, &str, Option<&str>)] = &[
// "vid,fid" — Go's default branch.
("/3,01637037d6", "3", "01637037d6", "", None),
("/3,01637037d6.jpg", "3", "01637037d6", ".jpg", None),
("/3,01637037d6_1", "3", "01637037d6_1", "", None),
("/3,01637037d6_1.jpg", "3", "01637037d6_1", ".jpg", None),
// "vid/fid" — Go's case 2.
("/3/01637037d6", "3", "01637037d6", "", None),
("/3/01637037d6.jpg", "3", "01637037d6", ".jpg", None),
("/3/01637037d6_1", "3", "01637037d6_1", "", None),
("/3/01637037d6_1.jpg", "3", "01637037d6_1", ".jpg", None),
// "vid/fid/filename" — Go's case 3: the fid stays whole.
(
"/3/01637037d6/report",
"3",
"01637037d6",
"",
Some("report"),
),
(
"/3/01637037d6/report.txt",
"3",
"01637037d6",
".txt",
Some("report.txt"),
),
(
"/3/01637037d6_1/report",
"3",
"01637037d6_1",
"",
Some("report"),
),
(
"/3/01637037d6_1/report.txt",
"3",
"01637037d6_1",
".txt",
Some("report.txt"),
),
// Mixed forms: the slash count decides, so a comma with a trailing
// segment is read as "vid/fid" (Go case 2, and the volume id then
// fails to parse) and a comma inside a filename is just part of
// the filename (Go case 3).
(
"/3,01637037d6/name.txt",
"3,01637037d6",
"name",
".txt",
None,
),
(
"/3/01637037d6/my,file.jpg",
"3",
"01637037d6",
".jpg",
Some("my,file.jpg"),
),
// Go's case 2 guards the extension split with `dotIndex > 0`, so a
// file id that is nothing but an extension keeps it and fails to
// parse later; filepath.Ext in case 3 has no such guard.
("/3/.jpg", "3", ".jpg", "", None),
(
"/3/01637037d6/.jpg",
"3",
"01637037d6",
".jpg",
Some(".jpg"),
),
];
for &(path, vid, fid, ext, filename) in cases {
assert_eq!(
parse_needle_path(path),
Some(NeedlePath {
vid,
fid,
ext,
filename
}),
"path {}",
path
);
// Chunk manifest entries arrive without the leading slash.
let unrooted = path.trim_start_matches('/');
assert_eq!(
parse_needle_path(unrooted),
parse_needle_path(path),
"path {}",
unrooted
);
}
}
/// A path with no file id at all is Go's `isVolumeIdOnly` case; every
/// caller here needs a file id, so it has to come back as `None`.
#[test]
fn test_parse_needle_path_rejects_paths_without_a_file_id() {
for path in ["", "/", "/3", "/invalid", "/not/a/valid/volume/path"] {
assert_eq!(parse_needle_path(path), None, "path {}", path);
}
}
/// Go's `maybeCheckJwtAuthorization` compares the token's `fid` claim
/// against `vid + "," + fid` whatever form the URL used
/// (volume_server_handlers.go:361-364), so the slash form has to produce
/// the comma form too or a JWT-protected read of `/3/01637037d6` can never
/// match its own token.
#[test]
fn test_extract_file_id_normalizes_every_url_form() {
for path in [
"/3,01637037d6",
"/3,01637037d6.jpg",
"/3,01637037d6_1",
"/3/01637037d6",
"/3/01637037d6.jpg",
"/3/01637037d6_1.jpg",
"/3/01637037d6/report.txt",
"/3/01637037d6_1/report.txt",
] {
assert_eq!(extract_file_id(path), "3,01637037d6", "path {}", path);
}
}
/// Go only drops the `_suffix` when `strings.LastIndex(fid, "_") > 0`
/// (volume_server_handlers.go:361-364), so a file id that starts with the
/// separator is compared whole rather than becoming empty.
#[test]
fn test_extract_file_id_keeps_a_leading_underscore() {
assert_eq!(extract_file_id("/3,_5"), "3,_5");
assert_eq!(extract_file_id("/3/_5"), "3,_5");
}
/// Go checks the JWT before it parses the volume id
/// (volume_server_handlers_read.go:142-156), and so does
/// get_or_head_handler_inner, which is why an unparsable path still has to
/// produce a comparison string: it decides 401 before parse_url_path gets
/// to decide 400.
#[test]
fn test_extract_file_id_falls_back_for_unparsable_paths() {
assert_eq!(extract_file_id("/invalid"), "invalid");
assert_eq!(extract_file_id("/3"), "3");
assert_eq!(
extract_file_id("/not/a/valid/volume/path"),
"not/a/valid/volume/path"
);
}
#[test]
fn test_parse_url_path_comma() {
let (vid, nid, cookie) = parse_url_path("/3,01637037d6").unwrap();
@@ -4154,42 +3920,16 @@ mod tests {
#[test]
fn test_parse_url_path_slash_with_filename() {
// A comma in the filename is part of the filename: the slash count
// already decided the form, as in Go's case 3.
for path in ["3/01637037d6/report.txt", "3/01637037d6/my,file.jpg"] {
let (vid, nid, cookie) = parse_url_path(path).unwrap();
assert_eq!(vid, VolumeId(3), "path {}", path);
assert_eq!(nid, NeedleId(0x01), "path {}", path);
assert_eq!(cookie, Cookie(0x637037d6), "path {}", path);
}
}
/// The `_delta` suffix stays on the fid through the path split and is
/// applied by parse_needle_id_cookie, as Go's ParsePath does.
#[test]
fn test_parse_url_path_applies_delta_suffix() {
for path in [
"/3,01637037d6_1",
"/3/01637037d6_1",
"/3/01637037d6_1/a.txt",
] {
let (vid, nid, cookie) = parse_url_path(path).unwrap();
assert_eq!(vid, VolumeId(3), "path {}", path);
assert_eq!(nid, NeedleId(0x02), "path {}", path);
assert_eq!(cookie, Cookie(0x637037d6), "path {}", path);
}
let result = parse_url_path("3/01637037d6/report.txt");
assert!(result.is_some());
let (vid, _, _) = result.unwrap();
assert_eq!(vid, VolumeId(3));
}
#[test]
fn test_parse_url_path_invalid() {
assert!(parse_url_path("/invalid").is_none());
assert!(parse_url_path("").is_none());
// Go reads this as case 2 with vid = "3,01637037d6", which NewVolumeId
// rejects, so the comma-with-a-trailing-segment form is a 400 here too.
assert!(parse_url_path("/3,01637037d6/name.txt").is_none());
// Go's `dotIndex > 0` guard leaves ".jpg" as the file id, which then
// fails to parse as a needle id.
assert!(parse_url_path("/3/.jpg").is_none());
}
#[test]
@@ -4471,34 +4211,6 @@ mod tests {
);
}
/// Go redirects to `vid,fid` with the extension already stripped: its
/// `parseURLPath` case 2 splits `.jpg` off the fid and
/// `proxyReqToTargetServer` formats `"%s/%s,%s"` from what is left
/// (common.go:224-233, volume_server_handlers_read.go:128-137). Built from
/// the real `build_proxy_request_info` so the parse and the Location header
/// are covered together.
#[test]
fn test_redirect_location_drops_extension_for_slash_form() {
let info =
build_proxy_request_info("/3/01637037d6.jpg", &HeaderMap::new(), "").expect("parses");
assert_eq!(info.vid_str, "3");
assert_eq!(info.fid_str, "01637037d6");
let target = VolumeLocation {
url: "volume.internal:8080".to_string(),
public_url: "volume.public:8080".to_string(),
grpc_port: 18080,
read_only: false,
read_only_can_delete: false,
};
let response = redirect_request(&info, &target, "http");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
"http://volume.internal:8080/3,01637037d6?proxied=true"
);
}
/// Master /dir/lookup often omits publicUrl when empty (Go `json:"publicUrl,omitempty"`).
/// Replication must still parse locations or every cross-DC write fails.
#[test]
@@ -4531,7 +4243,7 @@ mod tests {
/// replicated write to fail.
#[tokio::test]
async fn test_lookup_volume_strips_grpc_port_from_master_url() {
use axum::{Router, routing::get};
use axum::{routing::get, Router};
let app = Router::new().route(
"/dir/lookup",
+148 -119
View File
@@ -5,16 +5,17 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast;
use tracing::{error, info, warn};
use super::grpc_client::{GrpcDialOptions, connect_channel, master_client};
use super::grpc_client::{build_grpc_endpoint, GRPC_MAX_MESSAGE_SIZE};
use super::volume_server::VolumeServerState;
use crate::pb::master_pb;
use crate::pb::master_pb::seaweed_client::SeaweedClient;
use crate::pb::volume_server_pb;
use crate::remote_storage::s3_tier::{S3TierBackend, S3TierConfig};
use crate::storage::store::Store;
@@ -23,6 +24,7 @@ use crate::storage::volume_report::VolumeReportKey;
use crate::storage::volume_report_hash::report_hash;
const DUPLICATE_UUID_RETRY_MESSAGE: &str = "duplicate UUIDs detected, retrying connection";
const VOLUME_IO_ERROR_TOLERANCE: i32 = 3;
const MAX_DUPLICATE_UUID_RETRIES: u32 = 3;
/// Configuration for the heartbeat client.
@@ -117,9 +119,7 @@ pub async fn run_heartbeat_with_state(
if err_msg.contains(DUPLICATE_UUID_RETRY_MESSAGE) {
if duplicate_retry_count >= MAX_DUPLICATE_UUID_RETRIES {
error!(
"Shut down Volume Server due to persistent duplicate volume directories after 3 retries"
);
error!("Shut down Volume Server due to persistent duplicate volume directories after 3 retries");
error!(
"Please check if another volume server is using the same directory"
);
@@ -220,7 +220,7 @@ async fn check_with_master(config: &HeartbeatConfig, state: &Arc<VolumeServerSta
if changed {
state.metrics_notify.notify_waiters();
}
apply_storage_backends(&resp.storage_backends);
apply_storage_backends(state, &resp.storage_backends);
info!(
"Got master configuration from {}: metrics_address={}, metrics_interval={}s",
master_addr, resp.metrics_address, resp.metrics_interval_seconds
@@ -244,8 +244,17 @@ pub async fn try_get_master_configuration(
grpc_addr: &str,
tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>,
) -> Result<master_pb::GetMasterConfigurationResponse, Box<dyn std::error::Error>> {
let channel = connect_channel(grpc_addr, tls, GrpcDialOptions::unary()).await?;
let mut client = master_client(channel);
let channel = build_grpc_endpoint(grpc_addr, tls)?
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.connect()
.await?;
let mut client = SeaweedClient::with_interceptor(
channel,
super::request_id::outgoing_request_id_interceptor,
)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let resp = client
.get_master_configuration(master_pb::GetMasterConfigurationRequest {})
.await?;
@@ -380,14 +389,18 @@ async fn do_heartbeat(
pulse: Duration,
shutdown_rx: &mut broadcast::Receiver<()>,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let channel = connect_channel(
grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::long(),
)
.await?;
let channel = build_grpc_endpoint(grpc_addr, state.outgoing_grpc_tls.as_ref())?
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.connect()
.await?;
let mut client = master_client(channel);
let mut client = SeaweedClient::with_interceptor(
channel,
super::request_id::outgoing_request_id_interceptor,
)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let (tx, rx) = tokio::sync::mpsc::channel::<master_pb::Heartbeat>(32);
@@ -659,15 +672,16 @@ fn apply_metrics_push_settings(
true
}
/// Registers the master's S3 storage backends in the process-wide tier
/// registry, the single place both the tier-move handlers and `Volume` itself
/// resolve a backend from.
fn apply_storage_backends(storage_backends: &[master_pb::StorageBackend]) {
fn apply_storage_backends(
state: &VolumeServerState,
storage_backends: &[master_pb::StorageBackend],
) {
if storage_backends.is_empty() {
return;
}
let mut registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
let mut registry = state.s3_tier_registry.write().unwrap();
let mut global_registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap();
for backend in storage_backends {
@@ -698,6 +712,7 @@ fn apply_storage_backends(storage_backends: &[master_pb::StorageBackend]) {
backend.id.as_str()
};
register_s3_backend(&mut registry, backend, backend_id, &config);
register_s3_backend(&mut global_registry, backend, backend_id, &config);
}
}
@@ -937,8 +952,8 @@ fn build_heartbeat_with_ec_status(
let volume_size = vol.dat_file_size().unwrap_or(0);
let mut should_delete_volume = false;
if vol.should_quarantine() {
let (_, io_count, io_quarantined) = vol.get_io_error_state();
let (_, io_count, io_quarantined) = vol.get_io_error_state();
if io_quarantined || io_count >= VOLUME_IO_ERROR_TOLERANCE {
if !io_quarantined {
vol.mark_io_quarantined();
warn!(
@@ -956,7 +971,7 @@ fn build_heartbeat_with_ec_status(
// whose .dat legitimately lives in cloud storage. Only a present .dat is
// cached for 30s; a missing one is re-checked every heartbeat so the volume
// stays suppressed until the file returns. See issues/10004
if vol.file_count() > 0 && !vol.has_remote_file() {
if vol.file_count() > 0 && !vol.has_remote_file {
const DISK_CHECK_INTERVAL_NS: i64 = 30 * 1_000_000_000;
let now_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -966,11 +981,7 @@ fn build_heartbeat_with_ec_status(
> DISK_CHECK_INTERVAL_NS
{
if !Path::new(&vol.file_name(".dat")).exists() {
warn!(
"Volume {}: data file {} missing (held open as deleted FD) - not reporting to master",
vol.id.0,
vol.file_name(".dat")
);
warn!("Volume {}: data file {} missing (held open as deleted FD) - not reporting to master", vol.id.0, vol.file_name(".dat"));
continue;
}
vol.last_disk_check_ns.store(now_ns, Ordering::Relaxed);
@@ -1233,12 +1244,12 @@ mod tests {
READ_ONLY_LABEL_NO_WRITE_CAN_DELETE, READ_ONLY_LABEL_NO_WRITE_OR_DELETE,
READ_ONLY_VOLUME_GAUGE,
};
use crate::remote_storage::s3_tier::S3TierRegistry;
use crate::security::{Guard, SigningKey};
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::types::{DiskType, VolumeId};
use crate::storage::volume::VolumeSpec;
use std::sync::RwLock;
use crate::storage::types::{DiskType, Version, VolumeId};
use std::sync::atomic::Ordering;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
fn test_config() -> HeartbeatConfig {
@@ -1284,6 +1295,7 @@ mod tests {
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
s3_tier_registry: std::sync::RwLock::new(S3TierRegistry::new()),
read_mode: ReadMode::Local,
allow_untrusted_remote_endpoints: false,
master_url: String::new(),
@@ -1348,11 +1360,12 @@ mod tests {
store
.add_volume(
VolumeId(7),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
@@ -1397,11 +1410,12 @@ mod tests {
store
.add_volume(
VolumeId(7),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
@@ -1462,11 +1476,12 @@ mod tests {
store
.add_volume(
VolumeId(id),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
}
@@ -1515,11 +1530,12 @@ mod tests {
store
.add_volume(
VolumeId(3),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
@@ -1575,11 +1591,12 @@ mod tests {
store
.add_volume(
VolumeId(3),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
@@ -1617,11 +1634,12 @@ mod tests {
store
.add_volume(
vid,
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
}
@@ -1655,11 +1673,12 @@ mod tests {
store
.add_volume(
VolumeId(17),
"heartbeat_metrics_case",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "heartbeat_metrics_case",
..Default::default()
},
Version::current(),
)
.unwrap();
store.locations[0]
@@ -1669,9 +1688,8 @@ mod tests {
{
let (_, volume) = store.find_volume_mut(VolumeId(17)).unwrap();
volume.set_read_only().unwrap();
volume
.update_remote_files(|files| files.push(Default::default()))
.unwrap();
volume.volume_info.files.push(Default::default());
volume.refresh_remote_write_mode().unwrap();
}
let heartbeat = build_heartbeat(&test_config(), &mut store);
@@ -1758,11 +1776,12 @@ mod tests {
store
.add_volume(
VolumeId(21),
collection,
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection,
..Default::default()
},
Version::current(),
)
.unwrap();
{
@@ -1930,13 +1949,12 @@ mod tests {
store
.add_volume(
VolumeId(41),
"expired_volume_case",
None,
Some(crate::storage::needle::ttl::TTL::read("20m").unwrap()),
1024,
DiskType::HardDrive,
&VolumeSpec {
collection: "expired_volume_case",
ttl: Some(crate::storage::needle::ttl::TTL::read("20m").unwrap()),
preallocate: 1024,
..Default::default()
},
Version::current(),
)
.unwrap();
let dat_path = {
@@ -1987,11 +2005,12 @@ mod tests {
store
.add_volume(
VolumeId(51),
"io_error_case",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "io_error_case",
..Default::default()
},
Version::current(),
)
.unwrap();
let (_, volume) = store.find_volume_mut(VolumeId(51)).unwrap();
@@ -2027,24 +2046,25 @@ mod tests {
store
.add_volume(
VolumeId(71),
"remote_volume_case",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "remote_volume_case",
..Default::default()
},
Version::current(),
)
.unwrap();
let (_, volume) = store.find_volume_mut(VolumeId(71)).unwrap();
volume
.update_remote_files(|files| {
files.push(crate::storage::volume::PbRemoteFile {
backend_type: "s3".to_string(),
backend_id: "archive".to_string(),
key: "volumes/71.dat".to_string(),
..Default::default()
})
})
.unwrap();
.volume_info
.files
.push(crate::storage::volume::PbRemoteFile {
backend_type: "s3".to_string(),
backend_id: "archive".to_string(),
key: "volumes/71.dat".to_string(),
..Default::default()
});
volume.refresh_remote_write_mode().unwrap();
let heartbeat = build_heartbeat(&test_config(), &mut store);
@@ -2053,56 +2073,65 @@ mod tests {
assert_eq!(heartbeat.volumes[0].remote_storage_key, "volumes/71.dat");
}
// Not hermetic, and cannot be made so cheaply: `register_s3_backend` skips
// a name that is already registered, so had another test put `s3` or
// `s3.default` in the process-wide registry first, this would pass without
// proving that this call registered anything. Removing them afterwards is
// no better — unlike the tier tests' unique ids, the bare `s3` alias is the
// production one. Nothing else in the tree registers those two names.
#[test]
fn test_apply_storage_backends_registers_s3_default_aliases() {
let state = test_state_with_store(Store::new(NeedleMapKind::InMemory));
// Do not call clear() on the global registry — other tests may be
// running concurrently. Just register our entries and verify them.
apply_storage_backends(&[master_pb::StorageBackend {
r#type: "s3".to_string(),
id: "default".to_string(),
properties: std::collections::HashMap::from([
("aws_access_key_id".to_string(), "access".to_string()),
("aws_secret_access_key".to_string(), "secret".to_string()),
("bucket".to_string(), "bucket-a".to_string()),
("region".to_string(), "us-west-2".to_string()),
("endpoint".to_string(), "http://127.0.0.1:8333".to_string()),
("storage_class".to_string(), "STANDARD".to_string()),
("force_path_style".to_string(), "false".to_string()),
]),
}]);
apply_storage_backends(
&state,
&[master_pb::StorageBackend {
r#type: "s3".to_string(),
id: "default".to_string(),
properties: std::collections::HashMap::from([
("aws_access_key_id".to_string(), "access".to_string()),
("aws_secret_access_key".to_string(), "secret".to_string()),
("bucket".to_string(), "bucket-a".to_string()),
("region".to_string(), "us-west-2".to_string()),
("endpoint".to_string(), "http://127.0.0.1:8333".to_string()),
("storage_class".to_string(), "STANDARD".to_string()),
("force_path_style".to_string(), "false".to_string()),
]),
}],
);
let registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
.read()
.unwrap();
let registry = state.s3_tier_registry.read().unwrap();
assert!(registry.get("s3.default").is_some());
assert!(registry.get("s3").is_some());
let global_registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
.read()
.unwrap();
assert!(global_registry.get("s3.default").is_some());
assert!(global_registry.get("s3").is_some());
}
#[test]
fn test_apply_storage_backends_ignores_unsupported_types() {
let state = test_state_with_store(Store::new(NeedleMapKind::InMemory));
// Do not call clear() on the global registry — other tests may be
// running concurrently.
apply_storage_backends(&[master_pb::StorageBackend {
r#type: "rclone".to_string(),
id: "default".to_string(),
properties: std::collections::HashMap::new(),
}]);
apply_storage_backends(
&state,
&[master_pb::StorageBackend {
r#type: "rclone".to_string(),
id: "default".to_string(),
properties: std::collections::HashMap::new(),
}],
);
// The per-state registry is freshly created and should have no entries
// since "rclone" is unsupported.
let registry = state.s3_tier_registry.read().unwrap();
assert!(registry.names().is_empty());
// Only check that the unsupported type was not added to the global
// registry. Other tests may have their own entries present.
let registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
let global_registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
.read()
.unwrap();
assert!(registry.get("rclone.default").is_none());
assert!(registry.get("rclone").is_none());
assert!(global_registry.get("rclone.default").is_none());
assert!(global_registry.get("rclone").is_none());
}
#[test]
+9 -13
View File
@@ -36,20 +36,16 @@ pub fn collect_mem_status() -> volume_server_pb::MemStatus {
#[cfg(target_os = "linux")]
fn get_system_memory_linux() -> Option<(u64, u64)> {
// SAFETY: `libc::sysinfo` is plain data — integers and trailing padding,
// no pointers and no restricted niches — so the all-zero value is a valid
// one for the kernel to overwrite.
let mut info: libc::sysinfo = unsafe { std::mem::zeroed() };
// SAFETY: `&mut info` is a live, aligned, exclusive pointer to a
// `sysinfo` that the kernel only writes through, and its fields are read
// below only after the call reports success.
if unsafe { libc::sysinfo(&mut info) } != 0 {
return None;
unsafe {
let mut info: libc::sysinfo = std::mem::zeroed();
if libc::sysinfo(&mut info) == 0 {
let unit = info.mem_unit as u64;
let total = info.totalram as u64 * unit;
let free = info.freeram as u64 * unit;
return Some((total, free));
}
}
let unit = info.mem_unit as u64;
let total = info.totalram as u64 * unit;
let free = info.freeram as u64 * unit;
Some((total, free))
None
}
#[cfg(target_os = "linux")]
-80
View File
@@ -1,7 +1,3 @@
use tonic::Status;
use crate::storage::volume::VolumeError;
#[cfg(unix)]
pub mod debug;
pub mod grpc_client;
@@ -17,79 +13,3 @@ pub mod store_ec;
pub mod ui;
pub mod volume_server;
pub mod write_queue;
/// Map a storage error onto the gRPC code that describes it.
impl From<VolumeError> for Status {
fn from(err: VolumeError) -> Self {
let message = err.to_string();
match err {
VolumeError::NotFound | VolumeError::VolumeNotFound(_) => Status::not_found(message),
VolumeError::ReadOnly | VolumeError::NotEmpty => Status::failed_precondition(message),
VolumeError::InsufficientSpace { .. } => Status::resource_exhausted(message),
VolumeError::AlreadyExists => Status::already_exists(message),
_ => Status::internal(message),
}
}
}
/// Same mapping, with the RPC's own context prefixed (`compact volume 7: ...`).
pub fn status_with_context(context: &str, err: VolumeError) -> Status {
let status = Status::from(err);
Status::new(status.code(), format!("{context}: {}", status.message()))
}
/// Render a configured disk directory as an absolute path for display, so the
/// status JSON and the UI show the same thing for a relative `-dir`. Falls
/// back to the configured spelling when the current directory cannot be read.
pub(crate) fn absolute_display_path(path: &str) -> String {
let p = std::path::Path::new(path);
if p.is_absolute() {
return path.to_string();
}
std::env::current_dir()
.map(|cwd| cwd.join(p).to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::types::VolumeId;
#[test]
fn test_volume_error_maps_to_grpc_code() {
use tonic::Code;
let code = |e: VolumeError| Status::from(e).code();
assert_eq!(
code(VolumeError::VolumeNotFound(VolumeId(7))),
Code::NotFound
);
assert_eq!(code(VolumeError::NotFound), Code::NotFound);
assert_eq!(code(VolumeError::ReadOnly), Code::FailedPrecondition);
assert_eq!(
code(VolumeError::InsufficientSpace {
vid: VolumeId(7),
required: 2,
free: 1,
}),
Code::ResourceExhausted
);
assert_eq!(code(VolumeError::AlreadyExists), Code::AlreadyExists);
assert_eq!(code(VolumeError::NotInitialized), Code::Internal);
let status = status_with_context(
"compact volume 7",
VolumeError::InsufficientSpace {
vid: VolumeId(7),
required: 2,
free: 1,
},
);
assert_eq!(status.code(), Code::ResourceExhausted);
assert_eq!(
status.message(),
"compact volume 7: not enough free space: required 2, free 1"
);
}
}
+3 -3
View File
@@ -29,11 +29,11 @@ impl<S> Layer<S> for GrpcRequestIdLayer {
impl<S, B> Service<http::Request<B>> for GrpcRequestIdService<S>
where
S: Service<http::Request<B>, Response = http::Response<tonic::body::Body>> + Send + 'static,
S: Service<http::Request<B>, Response = http::Response<tonic::body::BoxBody>> + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Response = http::Response<tonic::body::BoxBody>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
@@ -57,7 +57,7 @@ where
let future = self.inner.call(request);
Box::pin(async move {
let mut response: http::Response<tonic::body::Body> =
let mut response: http::Response<tonic::body::BoxBody> =
scope_request_id(request_id.clone(), future).await?;
if let Ok(value) = HeaderValue::from_str(&request_id) {
response.headers_mut().insert("x-amz-request-id", value);
+166 -375
View File
@@ -26,7 +26,7 @@
//! cache write-back briefly reacquires the EcVolume's internal
//! `RwLock` so we do not contend with the Store-level lock at all.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::sync::Arc;
@@ -38,16 +38,15 @@ use reed_solomon_erasure::galois_8::ReedSolomon;
use tokio::sync::Semaphore;
use tonic::Request;
use crate::pb::master_pb::{self, LookupEcVolumeRequest};
use crate::pb::master_pb::{self, seaweed_client::SeaweedClient, LookupEcVolumeRequest};
use crate::pb::volume_server_pb::{
CopyFileRequest, VolumeEcBlobDeleteRequest, VolumeEcShardReadRequest,
volume_server_client::VolumeServerClient, CopyFileRequest, VolumeEcShardReadRequest,
};
use crate::server::grpc_client::{
GrpcDialOptions, connect_channel, master_client, parse_grpc_address, volume_server_client,
};
use crate::server::volume_server::{VolumeServerState, to_http_address};
use crate::storage::erasure_coding::ec_shard::{ShardId, shard_id_try_from};
use crate::storage::needle::needle::{Needle, NeedleError, get_actual_size};
use crate::server::grpc_client::{build_grpc_endpoint, parse_grpc_address, GRPC_MAX_MESSAGE_SIZE};
use crate::server::request_id::outgoing_request_id_interceptor;
use crate::server::volume_server::{to_http_address, VolumeServerState};
use crate::storage::erasure_coding::ec_shard::ShardId;
use crate::storage::needle::needle::{get_actual_size, Needle, NeedleError};
use crate::storage::store_ec_reconcile::EcVolumeMissingIndex;
use crate::storage::types::*;
use crate::storage::volume::volume_file_name;
@@ -187,19 +186,15 @@ pub async fn read_ec_shard_needle_distributed(
} => {
fetch_one_interval(
state,
EcInterval {
vid,
needle_id,
shard_id,
shard_offset,
size,
expected_encode_ts_ns: encode_ts_ns,
},
EcShardMap {
locations: shard_locations,
data_shards,
parity_shards,
},
vid,
needle_id,
shard_id,
shard_offset,
size,
shard_locations,
data_shards,
parity_shards,
encode_ts_ns,
)
.await
}
@@ -255,237 +250,6 @@ pub async fn read_ec_shard_needle_distributed(
Ok(Some(n))
}
/// What one EC delete RPC carries — `VolumeEcBlobDeleteRequest` minus tonic.
struct EcDeleteTarget<'a> {
vid: VolumeId,
collection: &'a str,
version: Version,
needle_id: NeedleId,
}
/// `Store.doDeleteNeedleFromAtLeastOneRemoteEcShards` in Go: journal the
/// tombstone on one holder of the needle's primary data shard, falling back
/// to any other shard holder when the primary has none. Exactly one node
/// journals — replicas of a shard hold identical .ecx copies, so journaling
/// on more than one would double the reported delete count.
///
/// `NotFound` means the volume or needle is gone; other errors mean every
/// reachable holder failed or no shard has a holder at all.
pub async fn delete_ec_shard_needle_distributed(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<()> {
let (
primary_shard_id,
collection,
version,
total_shards,
local_shards,
data_shards,
encode_ts_ns,
refreshed_at,
cached_locations,
) = {
let store = state.store.read().unwrap();
let ecv = store.find_ec_volume(vid).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("ec volume {} not mounted", vid.0),
)
})?;
let (_, _, intervals) = ecv.locate_needle(needle_id)?.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("needle {} not in ec volume {}", needle_id, vid.0),
)
})?;
let (shard_id, _) = intervals
.first()
.map(|i| ecv.interval_to_shard_id_and_offset(i))
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no intervals for needle"))?;
let (cached_locations, refreshed_at) = ecv.shard_locations_snapshot();
(
shard_id,
ecv.collection.clone(),
ecv.version,
ecv.data_shards + ecv.parity_shards,
local_shard_ids(ecv),
ecv.data_shards as usize,
ecv.encode_ts_ns,
refreshed_at,
cached_locations,
)
};
let target = EcDeleteTarget {
vid,
collection: &collection,
version,
needle_id,
};
// Holder addresses come from the same staleness-gated cache as the read
// path: a master LookupEcVolume only when due, merged on a complete reply.
let mut locations = cached_locations;
if claim_shard_locations_refresh(
state,
vid,
&locations,
refreshed_at,
data_shards,
total_shards as usize,
) {
match cached_lookup_ec_shard_locations(state, vid).await {
Ok(fresh) => {
match write_back_shard_locations(state, vid, fresh, data_shards, encode_ts_ns) {
Some(merged) => locations = merged,
None => mark_shard_locations_stale(state, vid),
}
}
Err(_) => mark_shard_locations_stale(state, vid),
}
}
match delete_on_ec_shard_holders(state, &locations, &local_shards, primary_shard_id, &target)
.await
{
Ok(true) => return Ok(()),
Err(e) => return Err(e),
Ok(false) => {}
}
for shard_id in 0..total_shards {
let Ok(shard_id) = shard_id_try_from(shard_id) else {
continue;
};
if shard_id == primary_shard_id {
continue;
}
if let Ok(true) =
delete_on_ec_shard_holders(state, &locations, &local_shards, shard_id, &target).await
{
return Ok(());
}
}
Err(io::Error::other(format!(
"ec volume {}: no shard holder could journal the delete",
vid.0
)))
}
/// `doDeleteNeedleFromRemoteEcShardServers` in Go. `Ok(false)` is the
/// shard-missing signal — no live holder anywhere — that triggers the
/// caller's fallback walk over the remaining shards.
async fn delete_on_ec_shard_holders(
state: &Arc<VolumeServerState>,
locations: &HashMap<ShardId, Vec<String>>,
local_shards: &HashSet<ShardId>,
shard_id: ShardId,
target: &EcDeleteTarget<'_>,
) -> io::Result<bool> {
let addrs = locations.get(&shard_id);
if !local_shards.contains(&shard_id) && addrs.is_none_or(|a| a.is_empty()) {
return Ok(false);
}
let mut last_err = None;
if local_shards.contains(&shard_id) {
match journal_delete_local(state, target.vid, target.needle_id) {
Ok(()) => return Ok(true),
// Nothing was committed — the volume unmounted or remounted
// without the needle — so it is safe to fall back to other
// shard holders, unlike an RPC failure which may have landed.
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(e) => last_err = Some(e),
}
}
if let Some(addrs) = addrs {
let self_http = to_http_address(&state.self_url);
for addr in addrs {
// A stale self entry: the loopback RPC would journal on this same
// volume, which the local attempt above already covered.
if to_http_address(addr).as_ref() == self_http.as_ref() {
continue;
}
match delete_on_remote_ec_shard(state, addr, target).await {
Ok(()) => return Ok(true),
Err(e) => last_err = Some(e),
}
}
}
match last_err {
Some(e) => Err(e),
None => Ok(false),
}
}
/// `doDeleteNeedleFromRemoteEcShard` in Go — one `VolumeEcBlobDelete` RPC.
async fn delete_on_remote_ec_shard(
state: &Arc<VolumeServerState>,
addr: &str,
target: &EcDeleteTarget<'_>,
) -> io::Result<()> {
let grpc_addr =
parse_grpc_address(addr).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let channel = connect_channel(
&grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::unary(),
)
.await
.map_err(|e| io::Error::other(format!("connect to {}: {}", addr, e)))?;
let mut client = volume_server_client(channel);
client
.volume_ec_blob_delete(Request::new(VolumeEcBlobDeleteRequest {
volume_id: target.vid.0,
collection: target.collection.to_string(),
file_key: target.needle_id.0,
version: target.version.0 as u32,
}))
.await
.map_err(|e| io::Error::other(format!("volume_ec_blob_delete on {}: {}", addr, e)))?;
Ok(())
}
/// Journals on the local volume — what the `VolumeEcBlobDelete` handler runs
/// when this server is the shard holder. An absent needle is an error, not a
/// no-op: `journal_delete` would accept it silently, but here it means the
/// volume remounted as a different generation mid-delete and the tombstone
/// should go to a replica that still has the needle.
fn journal_delete_local(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<()> {
let mut store = state.store.write().unwrap();
let ecv = store.find_ec_volume_mut(vid).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("ec volume {} unmounted", vid.0),
)
})?;
match ecv.find_needle_from_ecx(needle_id)? {
None => {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("needle {} not in local ecx", needle_id),
));
}
Some((_, size)) if size.is_deleted() => return Ok(()),
Some(_) => {}
}
ecv.journal_delete(needle_id)
}
fn local_shard_ids(ecv: &crate::storage::erasure_coding::EcVolume) -> HashSet<ShardId> {
ecv.shards
.iter()
.enumerate()
.filter_map(|(i, s)| s.as_ref().map(|_| i as ShardId))
.collect()
}
/// FULL EC scrub: verify every needle's bytes across local AND remote shards,
/// without decoding (so genuine shard faults are reported rather than healed).
/// Mirrors Go's `Store.ScrubEcVolume`. Returns (rows walked, broken shards,
@@ -532,7 +296,7 @@ pub async fn scrub_ec_volume_distributed(
0,
Vec::new(),
vec![format!("EC volume id {} not found", vid.0)],
);
)
}
};
// full scan means verifying the index as well
@@ -556,9 +320,9 @@ pub async fn scrub_ec_volume_distributed(
// mounted volume's encode_ts_ns no longer matches, abort like a
// mid-scan unmount rather than mixing generations.
let encode_ts_ns = ecv.encode_ts_ns;
// One read section, so the map and the refresh time it is aged against
// describe the same lookup.
let (cached_locations, cache_refreshed_at) = ecv.shard_locations_snapshot();
// Bind to locals so the inner RwLock/Mutex guards drop before the block ends.
let cached_locations = ecv.shard_locations.read().unwrap().clone();
let cache_refreshed_at = *ecv.shard_locations_refresh_time.lock().unwrap();
let data_shards = ecv.data_shards as usize;
let total_shards = (ecv.data_shards + ecv.parity_shards) as usize;
(
@@ -660,7 +424,7 @@ pub async fn scrub_ec_volume_distributed(
);
}
};
ecv.shard_locations_snapshot().0
ecv.shard_locations.read().unwrap().clone()
};
// Walk the .ecx (private fd captured under the lock, no lock held) for the
@@ -757,15 +521,18 @@ pub async fn scrub_ec_volume_distributed(
} => {
let sources: &[String] =
locations.get(shard_id).map(Vec::as_slice).unwrap_or(&[]);
let iv = EcInterval {
match read_remote_ec_shard_interval(
state,
sources,
vid,
needle_id: id,
shard_id: *shard_id,
shard_offset: *shard_offset,
size: *ssize,
expected_encode_ts_ns: snapshot.encode_ts_ns,
};
match read_remote_ec_shard_interval(state, sources, iv).await {
id,
*shard_id,
*shard_offset,
*ssize,
snapshot.encode_ts_ns,
)
.await
{
// A deleted shard yields no bytes; zero-fill the interval so
// the assembled needle reaches read_bytes -> SizeMismatch{0}
// -> the delete-state suppression (mirrors Go's pre-zeroed buffer).
@@ -793,12 +560,15 @@ pub async fn scrub_ec_volume_distributed(
}
match recover_one_remote_ec_shard_interval(
state,
iv,
EcShardMap {
locations: &locations,
data_shards,
parity_shards: total_shards - data_shards,
},
vid,
id,
*shard_id,
*shard_offset,
*ssize,
&locations,
data_shards,
total_shards - data_shards,
snapshot.encode_ts_ns,
)
.await
{
@@ -904,7 +674,7 @@ fn scrub_snapshot_under_lock(
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("EC volume {} not found (unmounted mid-scan)", vid.0),
));
))
}
};
// The volume was torn down and remounted as a DIFFERENT encode run between
@@ -1005,7 +775,8 @@ fn build_snapshot(
}
let actual = get_actual_size(size, ecv.version);
let interval_results = read_local_intervals(ecv, intervals);
let (cached_locations, cache_refreshed_at) = ecv.shard_locations_snapshot();
let cached_locations = ecv.shard_locations.read().unwrap().clone();
let cache_refreshed_at = *ecv.shard_locations_refresh_time.lock().unwrap();
Ok(Snapshot {
data_shards: ecv.data_shards,
@@ -1057,14 +828,14 @@ fn needs_refresh(
fn mark_shard_locations_stale(state: &Arc<VolumeServerState>, vid: VolumeId) {
let store = state.store.read().unwrap();
if let Some(ecv) = store.find_ec_volume(vid) {
ecv.mark_shard_locations_stale();
*ecv.shard_locations_stale.lock().unwrap() = true;
}
}
/// Decide whether the caller's snapshot is due a master lookup and, when it is,
/// consume the cache's stale mark in the same critical section. A mark raised
/// from here on belongs to the next refresh: the read that raised it has
/// disproved the map this lookup is about to install.
/// Decide whether the cached map is due a master lookup and, when it is, consume
/// its stale mark in the same critical section. A mark raised from here on
/// belongs to the next refresh: the read that raised it has disproved the map
/// this lookup is about to install.
fn claim_shard_locations_refresh(
state: &Arc<VolumeServerState>,
vid: VolumeId,
@@ -1077,9 +848,12 @@ fn claim_shard_locations_refresh(
let Some(ecv) = store.find_ec_volume(vid) else {
return needs_refresh(locations, refreshed_at, false, data_shards, total_shards);
};
ecv.claim_shard_locations_refresh(|stale| {
needs_refresh(locations, refreshed_at, stale, data_shards, total_shards)
})
let mut stale = ecv.shard_locations_stale.lock().unwrap();
let refresh = needs_refresh(locations, refreshed_at, *stale, data_shards, total_shards);
if refresh {
*stale = false;
}
refresh
}
async fn cached_lookup_ec_shard_locations(
@@ -1100,15 +874,18 @@ async fn cached_lookup_ec_shard_locations(
let grpc_addr =
parse_grpc_address(&master).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let channel = connect_channel(
&grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::unary(),
)
.await
.map_err(|e| io::Error::other(format!("master connect: {}", e)))?;
let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| io::Error::other(e.to_string()))?;
let channel = endpoint
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.connect()
.await
.map_err(|e| io::Error::other(format!("master connect: {}", e)))?;
let mut client = master_client(channel);
let mut client = SeaweedClient::with_interceptor(channel, outgoing_request_id_interceptor)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let resp = client
.lookup_ec_volume(Request::new(LookupEcVolumeRequest { volume_id: vid.0 }))
@@ -1123,12 +900,7 @@ async fn cached_lookup_ec_shard_locations(
.iter()
.map(format_location_as_server_address)
.collect();
// Defensive: skip out-of-range shard ids from the master instead of
// truncating (256 would alias 0). Valid replies are unaffected.
let Ok(sid) = shard_id_try_from(entry.shard_id) else {
continue;
};
out.insert(sid, addrs);
out.insert(entry.shard_id as ShardId, addrs);
}
Ok(out)
}
@@ -1193,44 +965,37 @@ fn format_location_as_server_address(loc: &master_pb::Location) -> String {
raw.to_string()
}
/// One shard-relative byte range of a needle on an EC volume, the unit the
/// peer-read and recovery paths work in. Mirrors the argument list of Go's
/// `readOneEcShardInterval`.
#[derive(Clone, Copy, Debug)]
struct EcInterval {
/// Try direct peer read; on failure, reconstruct via Reed-Solomon
/// from the other shards. Mirrors `readOneEcShardInterval`'s tail.
#[expect(clippy::too_many_arguments)]
async fn fetch_one_interval(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
/// The shard the bytes live on, or the one to rebuild when recovering.
shard_id: ShardId,
shard_offset: i64,
size: usize,
/// Encode run the caller expects the shard to belong to; 0 accepts any,
/// for peers that predate the identity check.
expected_encode_ts_ns: i64,
}
/// Where the shards of one EC volume can be fetched from, and the volume's
/// Reed-Solomon shape, as recovery needs both together.
#[derive(Clone, Copy)]
struct EcShardMap<'a> {
locations: &'a HashMap<ShardId, Vec<String>>,
shard_locations: &HashMap<ShardId, Vec<String>>,
data_shards: usize,
parity_shards: usize,
}
/// Try direct peer read; on failure, reconstruct via Reed-Solomon
/// from the other shards. Mirrors `readOneEcShardInterval`'s tail.
async fn fetch_one_interval(
state: &Arc<VolumeServerState>,
iv: EcInterval,
map: EcShardMap<'_>,
expected_encode_ts_ns: i64,
) -> io::Result<(Vec<u8>, bool)> {
let EcInterval { vid, shard_id, .. } = iv;
// Direct peer read against the cached locations for this shard.
if let Some(sources) = map.locations.get(&shard_id)
if let Some(sources) = shard_locations.get(&shard_id)
&& !sources.is_empty()
{
match read_remote_ec_shard_interval(state, sources, iv).await {
match read_remote_ec_shard_interval(
state,
sources,
vid,
needle_id,
shard_id,
shard_offset,
size,
expected_encode_ts_ns,
)
.await
{
// A deleted needle short-circuits: don't reconstruct (every shard
// would report deleted), let the caller return "deleted".
Ok((buf, is_deleted)) => return Ok((buf, is_deleted)),
@@ -1251,17 +1016,46 @@ async fn fetch_one_interval(
// Reconstruct: fan-out reads to every other shard at the same
// (shard_offset, size). Mirrors `recoverOneRemoteEcShardInterval`.
recover_one_remote_ec_shard_interval(state, iv, map).await
recover_one_remote_ec_shard_interval(
state,
vid,
needle_id,
shard_id,
shard_offset,
size,
shard_locations,
data_shards,
parity_shards,
expected_encode_ts_ns,
)
.await
}
#[expect(clippy::too_many_arguments)]
async fn read_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
sources: &[String],
iv: EcInterval,
vid: VolumeId,
needle_id: NeedleId,
shard_id: ShardId,
shard_offset: i64,
size: usize,
expected_encode_ts_ns: i64,
) -> io::Result<(Vec<u8>, bool)> {
let mut last_err: Option<io::Error> = None;
for src in sources {
match do_read_remote_ec_shard_interval(state, src, iv).await {
match do_read_remote_ec_shard_interval(
state,
src,
vid,
needle_id,
shard_id,
shard_offset,
size,
expected_encode_ts_ns,
)
.await
{
Ok(res) => return Ok(res),
Err(e) => last_err = Some(e),
}
@@ -1269,33 +1063,32 @@ async fn read_remote_ec_shard_interval(
Err(last_err.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("no source for ec shard {}.{}", iv.vid.0, iv.shard_id),
format!("no source for ec shard {}.{}", vid.0, shard_id),
)
}))
}
#[expect(clippy::too_many_arguments)]
async fn do_read_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
source: &str,
iv: EcInterval,
vid: VolumeId,
needle_id: NeedleId,
shard_id: ShardId,
shard_offset: i64,
size: usize,
expected_encode_ts_ns: i64,
) -> io::Result<(Vec<u8>, bool)> {
let EcInterval {
vid,
needle_id,
shard_id,
shard_offset,
size,
expected_encode_ts_ns,
} = iv;
let grpc_addr =
parse_grpc_address(source).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let channel = connect_channel(
&grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::long(),
)
.await
.map_err(|e| io::Error::other(format!("connect to {}: {}", source, e)))?;
let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| io::Error::other(e.to_string()))?;
let channel = endpoint
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.connect()
.await
.map_err(|e| io::Error::other(format!("connect to {}: {}", source, e)))?;
// TODO(grpc-jwt): clusters with `jwt.signing.key` configured will
// reject peer-to-peer VolumeEcShardRead calls until the Rust
@@ -1305,7 +1098,9 @@ async fn do_read_remote_ec_shard_interval(
// here in isolation would split the credential plumbing across
// call sites. Re-visit when outgoing JWT signing lands as a
// server-wide helper.
let mut client = volume_server_client(channel);
let mut client = VolumeServerClient::with_interceptor(channel, outgoing_request_id_interceptor)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let req = VolumeEcShardReadRequest {
volume_id: vid.0,
@@ -1376,24 +1171,19 @@ async fn do_read_remote_ec_shard_interval(
Ok((out, false))
}
#[expect(clippy::too_many_arguments)]
async fn recover_one_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
iv: EcInterval,
map: EcShardMap<'_>,
vid: VolumeId,
needle_id: NeedleId,
shard_id_to_recover: ShardId,
shard_offset: i64,
size: usize,
shard_locations: &HashMap<ShardId, Vec<String>>,
data_shards: usize,
parity_shards: usize,
expected_encode_ts_ns: i64,
) -> io::Result<(Vec<u8>, bool)> {
let EcInterval {
vid,
needle_id,
shard_id: shard_id_to_recover,
shard_offset,
size,
expected_encode_ts_ns,
} = iv;
let EcShardMap {
locations: shard_locations,
data_shards,
parity_shards,
} = map;
let total_shards = data_shards + parity_shards;
let rs = ReedSolomon::new(data_shards, parity_shards)
.map_err(|e| io::Error::other(format!("reed-solomon init: {:?}", e)))?;
@@ -1434,10 +1224,7 @@ async fn recover_one_remote_ec_shard_interval(
// shard from a different encode run must not be fed to Reed-Solomon;
// lenient only when the caller carries no identity (pre-upgrade).
// Mirrors Go's `readLocalEcShardInterval`.
let Ok(sid_shard) = ShardId::try_from(sid) else {
continue;
};
let owner = match store.find_ec_volume_with_shard(vid, sid_shard) {
let owner = match store.find_ec_volume_with_shard(vid, sid as u32) {
Some(ecv)
if expected_encode_ts_ns == 0 || ecv.encode_ts_ns == expected_encode_ts_ns =>
{
@@ -1485,10 +1272,12 @@ async fn recover_one_remote_ec_shard_interval(
let res = read_remote_ec_shard_interval(
&state,
&locs,
EcInterval {
shard_id: sid,
..iv
},
vid,
needle_id,
sid,
shard_offset,
size,
expected_encode_ts_ns,
)
.await;
(sid, res)
@@ -1693,14 +1482,16 @@ async fn fetch_ec_index_from_one_peer(
) -> io::Result<()> {
let grpc_addr =
parse_grpc_address(peer).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let channel = connect_channel(
&grpc_addr,
state.outgoing_grpc_tls.as_ref(),
GrpcDialOptions::long(),
)
.await
.map_err(|e| io::Error::other(format!("connect {}: {}", peer, e)))?;
let mut client = volume_server_client(channel);
let channel = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| io::Error::other(e.to_string()))?
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.connect()
.await
.map_err(|e| io::Error::other(format!("connect {}: {}", peer, e)))?;
let mut client = VolumeServerClient::with_interceptor(channel, outgoing_request_id_interceptor)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let copy_req = |ext: &str, ignore_not_found: bool| CopyFileRequest {
volume_id: m.vid.0,
+10 -1
View File
@@ -1,6 +1,5 @@
use std::fmt::Write as _;
use crate::server::absolute_display_path;
use crate::server::server_stats;
use crate::server::volume_server::VolumeServerState;
use crate::storage::store::Store;
@@ -451,6 +450,16 @@ fn collect_ui_data(
(disk_rows, volumes, remote_volumes, ec_volumes)
}
fn absolute_display_path(path: &str) -> String {
let p = std::path::Path::new(path);
if p.is_absolute() {
return path.to_string();
}
std::env::current_dir()
.map(|cwd| cwd.join(p).to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string())
}
fn join_i64(values: &[i64]) -> String {
values
.iter()
+17 -16
View File
@@ -14,12 +14,12 @@ use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::{Arc, RwLock};
use axum::{
Router,
extract::{Request, State, connect_info::ConnectInfo},
http::{HeaderValue, Method, StatusCode, header},
extract::{connect_info::ConnectInfo, Request, State},
http::{header, HeaderValue, Method, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{any, get},
Router,
};
use crate::config::ReadMode;
@@ -73,6 +73,8 @@ pub struct VolumeServerState {
pub volume_state_notify: tokio::sync::Notify,
/// Optional batched write queue for improved throughput under load.
pub write_queue: std::sync::OnceLock<WriteQueue>,
/// Registry of S3 tier backends for tiered storage operations.
pub s3_tier_registry: std::sync::RwLock<crate::remote_storage::s3_tier::S3TierRegistry>,
/// Read mode: local, proxy, or redirect for non-local volumes.
pub read_mode: ReadMode,
/// If true, FetchAndWriteNeedle skips remote S3 endpoint validation,
@@ -198,7 +200,9 @@ pub fn to_http_address(addr: &str) -> std::borrow::Cow<'_, str> {
// rather than being silently rewritten. Mirrors the validation already
// done in `to_grpc_address` for the inverse direction.
if let (Ok(_), Ok(_)) = (http_port.parse::<u16>(), grpc_port.parse::<u16>()) {
return std::borrow::Cow::Owned(addr[..ports_sep_index + 1 + dot_idx].to_string());
return std::borrow::Cow::Owned(
addr[..ports_sep_index + 1 + dot_idx].to_string(),
);
}
}
std::borrow::Cow::Borrowed(addr)
@@ -426,13 +430,13 @@ pub fn build_admin_router_with_ui(state: Arc<VolumeServerState>, ui_enabled: boo
.route("/healthz", get(handlers::healthz_handler))
.route("/favicon.ico", get(handlers::favicon_handler))
.route(
"/seaweedfsstatic/{*path}",
"/seaweedfsstatic/*path",
get(handlers::static_asset_handler),
)
.route("/", any(admin_store_handler))
.route("/{path}", any(admin_store_handler))
.route("/{vid}/{fid}", any(admin_store_handler))
.route("/{vid}/{fid}/{filename}", any(admin_store_handler))
.route("/:path", any(admin_store_handler))
.route("/:vid/:fid", any(admin_store_handler))
.route("/:vid/:fid/:filename", any(admin_store_handler))
.fallback(admin_store_handler);
if ui_enabled {
// Note: /stats/* endpoints are commented out in Go's volume_server.go (L130-134).
@@ -449,13 +453,13 @@ pub fn build_public_router(state: Arc<VolumeServerState>) -> Router {
Router::new()
.route("/favicon.ico", get(handlers::favicon_handler))
.route(
"/seaweedfsstatic/{*path}",
"/seaweedfsstatic/*path",
get(handlers::static_asset_handler),
)
.route("/", any(public_store_handler))
.route("/{path}", any(public_store_handler))
.route("/{vid}/{fid}", any(public_store_handler))
.route("/{vid}/{fid}/{filename}", any(public_store_handler))
.route("/:path", any(public_store_handler))
.route("/:vid/:fid", any(public_store_handler))
.route("/:vid/:fid/:filename", any(public_store_handler))
.fallback(public_store_handler)
.layer(middleware::from_fn(common_headers_middleware))
.with_state(state)
@@ -510,10 +514,7 @@ mod tests {
// "host:abc.def"), and silently rewriting it would just hide the bug.
assert_eq!(to_http_address("host:abc.def"), "host:abc.def");
assert_eq!(to_http_address("host:9333.notaport"), "host:9333.notaport");
assert_eq!(
to_http_address("host:notaport.19333"),
"host:notaport.19333"
);
assert_eq!(to_http_address("host:notaport.19333"), "host:notaport.19333");
// Out-of-range ports must not be silently truncated either.
assert_eq!(to_http_address("host:99999.19333"), "host:99999.19333");
}
+4 -1
View File
@@ -178,8 +178,8 @@ mod tests {
use crate::server::volume_server::RuntimeMetricsConfig;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::store::Store;
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32};
use std::sync::RwLock;
let store = Store::new(NeedleMapKind::InMemory);
let guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0);
@@ -207,6 +207,9 @@ mod tests {
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
s3_tier_registry: std::sync::RwLock::new(
crate::remote_storage::s3_tier::S3TierRegistry::new(),
),
read_mode: crate::config::ReadMode::Local,
allow_untrusted_remote_endpoints: false,
master_url: String::new(),
+159 -232
View File
@@ -15,15 +15,15 @@ use tracing::warn;
use crate::config::MinFreeSpace;
use crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars;
use crate::storage::erasure_coding::ec_shard::{
DATA_SHARDS_COUNT, ERASURE_CODING_LARGE_BLOCK_SIZE, ERASURE_CODING_SMALL_BLOCK_SIZE,
EcVolumeShard, ShardId,
EcVolumeShard, DATA_SHARDS_COUNT, ERASURE_CODING_LARGE_BLOCK_SIZE,
ERASURE_CODING_SMALL_BLOCK_SIZE,
};
use crate::storage::erasure_coding::ec_volume::EcVolume;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::super_block::SUPER_BLOCK_SIZE;
use crate::storage::super_block::{ReplicaPlacement, SUPER_BLOCK_SIZE};
use crate::storage::types::*;
use crate::storage::volume::{
VifVolumeInfo, Volume, VolumeError, VolumeSpec, remove_volume_files, volume_file_name,
remove_volume_files, volume_file_name, VifVolumeInfo, Volume, VolumeError,
};
/// A single disk location managing volumes in one directory.
@@ -205,6 +205,7 @@ impl DiskLocation {
continue;
}
// Load existing data only; never create a phantom `.dat`. A lone
// `.vif`/`.idx` (e.g. an EC sidecar whose `.ecx` is on a sibling
// disk) would otherwise have Volume::new write an 8-byte stub that
@@ -279,33 +280,30 @@ impl DiskLocation {
let opened = Mutex::new(Vec::with_capacity(to_load.len()));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some((vid, collections)) = to_load.get(i) else {
return;
};
for collection in collections {
// Replica placement and TTL are read back from the
// superblock, and a load never preallocates.
match Volume::new(
&self.directory,
&self.idx_directory,
*vid,
needle_map_kind,
&VolumeSpec {
collection,
..Default::default()
},
) {
Ok(mut v) => {
v.location_disk_space_low = self.is_disk_space_low.clone();
opened.lock().unwrap().push((collection.clone(), *vid, v));
break;
}
Err(e) => {
warn!(volume_id = vid.0, error = %e, "failed to load volume");
}
scope.spawn(|| loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some((vid, collections)) = to_load.get(i) else {
return;
};
for collection in collections {
match Volume::new(
&self.directory,
&self.idx_directory,
collection,
*vid,
needle_map_kind,
None, // replica placement read from superblock
None, // TTL read from superblock
0, // no preallocate on load
Version::current(),
) {
Ok(mut v) => {
v.location_disk_space_low = self.is_disk_space_low.clone();
opened.lock().unwrap().push((collection.clone(), *vid, v));
break;
}
Err(e) => {
warn!(volume_id = vid.0, error = %e, "failed to load volume");
}
}
}
@@ -376,10 +374,8 @@ impl DiskLocation {
let mut expected_shard_size: Option<i64> = None;
let dat_exists = match fs::metadata(&dat_path) {
Ok(meta) if meta.len() > SUPER_BLOCK_SIZE as u64 => {
expected_shard_size = Some(calculate_expected_shard_size(
meta.len() as i64,
data_shards,
));
expected_shard_size =
Some(calculate_expected_shard_size(meta.len() as i64, data_shards));
true
}
Ok(_) => false,
@@ -403,13 +399,7 @@ impl DiskLocation {
if size != prev {
// Inconsistent sizes signal corruption or mixed
// generations; not trusted for deletion -> keep.
warn!(
volume_id = vid.0,
shard = i,
size,
expected = prev,
"EC shard size mismatch; keeping shards"
);
warn!(volume_id = vid.0, shard = i, size, expected = prev, "EC shard size mismatch; keeping shards");
return true;
}
} else {
@@ -557,22 +547,31 @@ impl DiskLocation {
}
/// Create a new volume in this location.
#[expect(clippy::too_many_arguments)]
pub fn create_volume(
&mut self,
vid: VolumeId,
collection: &str,
needle_map_kind: NeedleMapKind,
spec: &VolumeSpec<'_>,
replica_placement: Option<ReplicaPlacement>,
ttl: Option<crate::storage::needle::ttl::TTL>,
preallocate: u64,
version: Version,
) -> Result<(), VolumeError> {
let mut v = Volume::new(
&self.directory,
&self.idx_directory,
collection,
vid,
needle_map_kind,
spec,
replica_placement,
ttl,
preallocate,
version,
)?;
v.location_disk_space_low = self.is_disk_space_low.clone();
crate::metrics::VOLUME_GAUGE
.with_label_values(&[spec.collection, "volume"])
.with_label_values(&[collection, "volume"])
.inc();
self.volumes.insert(vid, v);
Ok(())
@@ -676,7 +675,8 @@ impl DiskLocation {
pub fn free_volume_count(&self) -> i32 {
use crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
let max = self.max_volume_count.load(Ordering::Relaxed);
let free_count = (max as i64 - self.volumes.len() as i64) * DATA_SHARDS_COUNT as i64
let free_count = (max as i64 - self.volumes.len() as i64)
* DATA_SHARDS_COUNT as i64
- self.ec_shard_count() as i64;
let effective_free = free_count / DATA_SHARDS_COUNT as i64;
if effective_free > 0 {
@@ -817,7 +817,7 @@ impl DiskLocation {
&mut self,
vid: VolumeId,
collection: &str,
shard_ids: &[ShardId],
shard_ids: &[u32],
source_disk_type: &str,
) -> Result<(), VolumeError> {
let idx_dir = self.idx_directory.clone();
@@ -839,7 +839,7 @@ impl DiskLocation {
&mut self,
vid: VolumeId,
collection: &str,
shard_ids: &[ShardId],
shard_ids: &[u32],
idx_dir: &str,
source_disk_type: &str,
) -> Result<(), VolumeError> {
@@ -851,10 +851,14 @@ impl DiskLocation {
// propagate the error to the caller.
let created = !self.ec_volumes.contains_key(&vid);
if created {
let ec_vol = EcVolume::new(&dir, idx_dir, collection, vid).map_err(VolumeError::Io)?;
let ec_vol = EcVolume::new(&dir, idx_dir, collection, vid)
.map_err(VolumeError::Io)?;
self.ec_volumes.insert(vid, ec_vol);
}
let ec_vol = self.ec_volumes.get_mut(&vid).expect("just inserted above");
let ec_vol = self
.ec_volumes
.get_mut(&vid)
.expect("just inserted above");
// When the orchestrator supplied a source disk type on the Mount
// RPC, override the EC volume's disk type so heartbeats report
// under the source volume's disk type (#9423). When the caller
@@ -873,10 +877,10 @@ impl DiskLocation {
// keep the existing registration (mirrors Go's AddEcVolumeShard
// added=false) — re-adding would replace a serving fd and bump
// the ec_shards gauge without growing the mounted count.
if ec_vol.has_shard(shard_id) {
if ec_vol.has_shard(shard_id as u8) {
continue;
}
let mut shard = EcVolumeShard::new(&dir, collection, vid, shard_id);
let mut shard = EcVolumeShard::new(&dir, collection, vid, shard_id as u8);
shard.disk_type = ec_vol.disk_type.clone();
if let Err(e) = ec_vol.add_shard(shard) {
// The shard was dropped (its descriptors closed) inside the
@@ -904,14 +908,14 @@ impl DiskLocation {
/// caller passes a shard that lives on a sibling disk
/// (cross-disk reconcile makes that the common case for the same
/// `vid` after reconciliation).
pub fn unmount_ec_shards(&mut self, vid: VolumeId, shard_ids: &[ShardId]) {
pub fn unmount_ec_shards(&mut self, vid: VolumeId, shard_ids: &[u32]) {
if let Some(ec_vol) = self.ec_volumes.get_mut(&vid) {
let collection = ec_vol.collection.clone();
for &shard_id in shard_ids {
if !ec_vol.has_shard(shard_id) {
if !ec_vol.has_shard(shard_id as u8) {
continue;
}
let _ = ec_vol.remove_shard(shard_id);
ec_vol.remove_shard(shard_id as u8);
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&collection, "ec_shards"])
.dec();
@@ -971,7 +975,7 @@ impl DiskLocation {
}
entries.sort();
let mut same_volume_shards: Vec<(String, ShardId)> = Vec::new(); // (filename, shard_id)
let mut same_volume_shards: Vec<(String, u32)> = Vec::new(); // (filename, shard_id)
let mut prev_vid: Option<VolumeId> = None;
let mut prev_collection: String = String::new();
@@ -1036,12 +1040,7 @@ impl DiskLocation {
/// Validate + mount a (collection, vid) group when its `.ecx` is
/// found. Mirrors `handleFoundEcxFile` in
/// `weed/storage/disk_location_ec.go`.
fn handle_found_ecx_file(
&mut self,
shards: &[(String, ShardId)],
collection: &str,
vid: VolumeId,
) {
fn handle_found_ecx_file(&mut self, shards: &[(String, u32)], collection: &str, vid: VolumeId) {
let base = volume_file_name(&self.directory, collection, vid);
let dat_path = format!("{}.dat", base);
let dat_exists = check_dat_file_exists(&dat_path);
@@ -1055,7 +1054,7 @@ impl DiskLocation {
return;
}
let shard_ids: Vec<ShardId> = shards.iter().map(|(_, sid)| *sid).collect();
let shard_ids: Vec<u32> = shards.iter().map(|(_, sid)| *sid).collect();
if let Err(e) = self.mount_ec_shards(vid, collection, &shard_ids, "") {
// A mount failure (corrupt/locked .ecx, EMFILE, transient I/O) is
// not proof the shards are disposable -- validate_ec_volume already
@@ -1064,7 +1063,8 @@ impl DiskLocation {
// delete on a load error.
warn!(
volume_id = vid.0,
"Failed to load EC shards: {}; keeping files for retry", e,
"Failed to load EC shards: {}; keeping files for retry",
e,
);
self.unmount_ec_shards(vid, &shard_ids);
}
@@ -1077,7 +1077,7 @@ impl DiskLocation {
/// distributed-EC shards waiting for cross-disk reconciliation.
fn check_orphaned_shards(
&self,
shards: &[(String, ShardId)],
shards: &[(String, u32)],
collection: &str,
vid: VolumeId,
) -> bool {
@@ -1133,60 +1133,20 @@ pub fn get_disk_stats(path: &str) -> (u64, u64) {
Ok(p) => p,
Err(_) => return (0, 0),
};
// SAFETY: `libc::statvfs` is plain data — integers and reserved
// padding, no pointers and no restricted niches — so the all-zero
// value is a valid one for the call to overwrite.
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: `c_path` is a live NUL-terminated `CString` that outlives
// the call, and `&mut stat` is a live, aligned, exclusive pointer the
// kernel only writes through; the fields are read below only after
// the call reports success.
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
return (0, 0);
}
let all = stat.f_blocks as u64 * stat.f_frsize as u64;
let free = stat.f_bavail as u64 * stat.f_frsize as u64;
(all, free)
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
// Canonicalize so symlinks, `.`/`..` segments, and relative paths
// resolve to the real location before querying. `\\?\`-prefixed
// extended-length paths and UNC (`\\?\UNC\...`) are passed through
// untouched: GetDiskFreeSpaceExW accepts them as-is.
let canonical = match std::fs::canonicalize(path) {
Ok(p) => p,
Err(_) => return (0, 0),
};
// UTF-16 with trailing NUL for the Win32 wide-string call.
let mut wide: Vec<u16> = canonical.as_os_str().encode_wide().collect();
// UNC directory names must end in a backslash for GetDiskFreeSpaceExW.
if !wide.ends_with(&[0x5C]) {
wide.push(0x5C);
}
wide.push(0);
// SAFETY: `wide` is NUL-terminated; the out-params are valid u64
// writes; the call has no other preconditions.
unsafe {
let mut free_available: u64 = 0;
let mut total: u64 = 0;
let ok = windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut free_available,
&mut total,
std::ptr::null_mut(),
);
if ok == 0 {
return (0, 0);
let mut stat: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 {
let all = stat.f_blocks as u64 * stat.f_frsize as u64;
let free = stat.f_bavail as u64 * stat.f_frsize as u64;
return (all, free);
}
return (total, free_available);
}
(0, 0)
}
#[cfg(not(any(unix, windows)))]
#[cfg(not(unix))]
{
compile_error!("get_disk_stats is implemented for unix and windows only");
let _ = path;
(0, 0)
}
}
@@ -1222,12 +1182,7 @@ fn rm_if_present(path: String) -> io::Result<()> {
}
}
fn ec_data_shards_from_vif(
directory: &str,
idx_directory: &str,
collection: &str,
vid: VolumeId,
) -> usize {
fn ec_data_shards_from_vif(directory: &str, idx_directory: &str, collection: &str, vid: VolumeId) -> usize {
for dir in [directory, idx_directory] {
let vif = format!("{}.vif", volume_file_name(dir, collection, vid));
if let Some(ds) = fs::read_to_string(&vif)
@@ -1273,7 +1228,7 @@ fn parse_collection_volume_id(base: &str) -> Option<(String, VolumeId)> {
/// `pub(crate)` re-export of [`parse_ec_shard_extension`] for the
/// cross-disk reconcile in `store_ec_reconcile.rs`.
pub(crate) fn is_ec_shard_extension(ext: &str) -> Option<ShardId> {
pub(crate) fn is_ec_shard_extension(ext: &str) -> Option<u32> {
parse_ec_shard_extension(ext)
}
@@ -1287,7 +1242,7 @@ pub(crate) fn is_ec_shard_extension(ext: &str) -> Option<ShardId> {
/// shardId > 255` guard. The 3-digit form (`.ec100`–`.ec255`) is
/// retained so the parser can still recognise shards from custom
/// 32+ ratios that fit in a u8 even though OSS only ships 10+4.
fn parse_ec_shard_extension(ext: &str) -> Option<ShardId> {
fn parse_ec_shard_extension(ext: &str) -> Option<u32> {
let rest = ext.strip_prefix(".ec")?;
if rest.len() < 2 || rest.len() > 3 {
return None;
@@ -1296,7 +1251,7 @@ fn parse_ec_shard_extension(ext: &str) -> Option<ShardId> {
if id > 255 {
return None;
}
ShardId::try_from(id).ok()
Some(id)
}
/// Robust check that a `.dat` with actual data exists. An empty `.dat`
@@ -1358,10 +1313,7 @@ fn remove_empty_ec_dat_stub(volume_name: &str, idx_name: &str, vid: VolumeId) ->
return false;
}
warn!(
volume_id = vid.0,
"removing leftover empty .dat stub for EC volume"
);
warn!(volume_id = vid.0, "removing leftover empty .dat stub for EC volume");
let _ = fs::remove_file(&dat_path);
let _ = fs::remove_file(format!("{}.idx", idx_name));
true
@@ -1384,17 +1336,6 @@ mod tests {
use super::*;
use tempfile::TempDir;
/// get_disk_stats must report real capacity for a real path on every
/// platform (Windows included) — consumers treat total==0 as "unknown"
/// and leave available_space at 0, which breaks volume assignment.
#[test]
fn test_get_disk_stats_reports_capacity_for_real_path() {
let tmp = TempDir::new().unwrap();
let (total, free) = get_disk_stats(tmp.path().to_str().unwrap());
assert!(total > 0, "expected total>0, got {total}");
assert!(free > 0, "expected free>0, got {free}");
}
/// When `-dir.idx` is configured the EC `.vif` may live in the idx
/// directory; the sweep must look there too, not only the data dir.
#[test]
@@ -1417,11 +1358,7 @@ mod tests {
}),
..Default::default()
};
std::fs::write(
format!("{}.vif", ibase),
serde_json::to_string(&vif).unwrap(),
)
.unwrap();
std::fs::write(format!("{}.vif", ibase), serde_json::to_string(&vif).unwrap()).unwrap();
assert!(
remove_empty_ec_dat_stub(&vbase, &ibase, VolumeId(42)),
@@ -1437,30 +1374,16 @@ mod tests {
fn test_validate_ec_volume_partial_dat_next_to_full_shards_keeps() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let loc = DiskLocation::new(
dir,
dir,
10,
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
let loc = DiskLocation::new(dir, dir, 10, DiskType::HardDrive, MinFreeSpace::Percent(1.0), Vec::new()).unwrap();
let base = volume_file_name(dir, "", VolumeId(70));
let ds = crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
let full = calculate_expected_shard_size(30 * 1024 * 1024, ds);
for i in 0..ds {
std::fs::File::create(format!("{}.ec{:02}", base, i))
.unwrap()
.set_len(full as u64)
.unwrap();
std::fs::File::create(format!("{}.ec{:02}", base, i)).unwrap().set_len(full as u64).unwrap();
}
// Partial .dat: bigger than a superblock so it is not swept as a stub,
// but smaller than what these shards encode.
std::fs::File::create(format!("{}.dat", base))
.unwrap()
.set_len(5 * 1024 * 1024)
.unwrap();
std::fs::File::create(format!("{}.dat", base)).unwrap().set_len(5 * 1024 * 1024).unwrap();
assert!(
loc.validate_ec_volume("", VolumeId(70)),
"full-size shards beside a smaller (stale/partial) .dat must be kept",
@@ -1474,29 +1397,15 @@ mod tests {
fn test_validate_ec_volume_interrupted_encode_reclaims() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let loc = DiskLocation::new(
dir,
dir,
10,
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
let loc = DiskLocation::new(dir, dir, 10, DiskType::HardDrive, MinFreeSpace::Percent(1.0), Vec::new()).unwrap();
let base = volume_file_name(dir, "", VolumeId(71));
let ds = crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
let dat_size = 30 * 1024 * 1024i64;
std::fs::File::create(format!("{}.dat", base))
.unwrap()
.set_len(dat_size as u64)
.unwrap();
std::fs::File::create(format!("{}.dat", base)).unwrap().set_len(dat_size as u64).unwrap();
let partial = calculate_expected_shard_size(dat_size, ds) / 3;
assert!(partial > 0);
for i in 0..ds {
std::fs::File::create(format!("{}.ec{:02}", base, i))
.unwrap()
.set_len(partial as u64)
.unwrap();
std::fs::File::create(format!("{}.ec{:02}", base, i)).unwrap().set_len(partial as u64).unwrap();
}
assert!(
!loc.validate_ec_volume("", VolumeId(71)),
@@ -1541,11 +1450,7 @@ mod tests {
}),
..Default::default()
};
std::fs::write(
format!("{}.vif", dbase),
serde_json::to_string(&with_gen).unwrap(),
)
.unwrap();
std::fs::write(format!("{}.vif", dbase), serde_json::to_string(&with_gen).unwrap()).unwrap();
assert_eq!(loc.ec_generation_ts_ns("", vid), Some(4242));
// A .vif with no EC config reads as generation 0 (recovered/pre-upgrade live volume).
@@ -1554,20 +1459,12 @@ mod tests {
version: 3,
..Default::default()
};
std::fs::write(
format!("{}.vif", dbase),
serde_json::to_string(&no_cfg).unwrap(),
)
.unwrap();
std::fs::write(format!("{}.vif", dbase), serde_json::to_string(&no_cfg).unwrap()).unwrap();
assert_eq!(loc.ec_generation_ts_ns("", vid), Some(0));
// idx-dir fallback: only the idx dir holds the .vif.
std::fs::remove_file(format!("{}.vif", dbase)).unwrap();
std::fs::write(
format!("{}.vif", ibase),
serde_json::to_string(&with_gen).unwrap(),
)
.unwrap();
std::fs::write(format!("{}.vif", ibase), serde_json::to_string(&with_gen).unwrap()).unwrap();
assert_eq!(loc.ec_generation_ts_ns("", vid), Some(4242));
}
@@ -1607,8 +1504,16 @@ mod tests {
)
.unwrap();
loc.create_volume(VolumeId(1), NeedleMapKind::InMemory, &VolumeSpec::default())
.unwrap();
loc.create_volume(
VolumeId(1),
"",
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert_eq!(loc.volumes_len(), 1);
assert!(loc.find_volume(VolumeId(1)).is_some());
@@ -1632,15 +1537,24 @@ mod tests {
Vec::new(),
)
.unwrap();
loc.create_volume(VolumeId(1), NeedleMapKind::InMemory, &VolumeSpec::default())
.unwrap();
loc.create_volume(
VolumeId(1),
"",
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
loc.create_volume(
VolumeId(2),
"test",
NeedleMapKind::InMemory,
&VolumeSpec {
collection: "test",
..Default::default()
},
None,
None,
0,
Version::current(),
)
.unwrap();
loc.close();
@@ -1683,11 +1597,12 @@ mod tests {
.unwrap();
loc.create_volume(
VolumeId(9),
"good",
NeedleMapKind::InMemory,
&VolumeSpec {
collection: "good",
..Default::default()
},
None,
None,
0,
Version::current(),
)
.unwrap();
loc.close();
@@ -1731,10 +1646,26 @@ mod tests {
)
.unwrap();
loc.create_volume(VolumeId(1), NeedleMapKind::InMemory, &VolumeSpec::default())
.unwrap();
loc.create_volume(VolumeId(2), NeedleMapKind::InMemory, &VolumeSpec::default())
.unwrap();
loc.create_volume(
VolumeId(1),
"",
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
loc.create_volume(
VolumeId(2),
"",
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert_eq!(loc.volumes_len(), 2);
loc.delete_volume(VolumeId(1), false, false).unwrap();
@@ -1758,29 +1689,32 @@ mod tests {
loc.create_volume(
VolumeId(1),
"pics",
NeedleMapKind::InMemory,
&VolumeSpec {
collection: "pics",
..Default::default()
},
None,
None,
0,
Version::current(),
)
.unwrap();
loc.create_volume(
VolumeId(2),
"pics",
NeedleMapKind::InMemory,
&VolumeSpec {
collection: "pics",
..Default::default()
},
None,
None,
0,
Version::current(),
)
.unwrap();
loc.create_volume(
VolumeId(3),
"docs",
NeedleMapKind::InMemory,
&VolumeSpec {
collection: "docs",
..Default::default()
},
None,
None,
0,
Version::current(),
)
.unwrap();
assert_eq!(loc.volumes_len(), 3);
@@ -1843,8 +1777,7 @@ mod tests {
// mount_ec_shards with source_disk_type="ssd" — simulating the
// VolumeEcShardsMount RPC path.
std::fs::write(format!("{}/pics_7.ec00", dir), b"ec-shard").unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0], "ssd")
.unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0], "ssd").unwrap();
{
let ec_vol = loc.find_ec_volume(VolumeId(7)).expect("ec volume mounted");
assert_eq!(
@@ -1861,9 +1794,7 @@ mod tests {
std::fs::write(format!("{}/pics_7.ec01", dir), b"ec-shard").unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[1], "").unwrap();
{
let ec_vol = loc
.find_ec_volume(VolumeId(7))
.expect("ec volume still mounted");
let ec_vol = loc.find_ec_volume(VolumeId(7)).expect("ec volume still mounted");
assert_eq!(
ec_vol.disk_type,
DiskType::Ssd,
@@ -1945,8 +1876,7 @@ mod tests {
let gauge = crate::metrics::VOLUME_GAUGE.with_label_values(&["dupmount", "ec_shards"]);
let before = gauge.get();
loc.mount_ec_shards(VolumeId(11), "dupmount", &[0], "")
.unwrap();
loc.mount_ec_shards(VolumeId(11), "dupmount", &[0], "").unwrap();
loc.mount_ec_shards(VolumeId(11), "dupmount", &[0], "")
.expect("a duplicate mount must succeed as a no-op");
@@ -2022,11 +1952,8 @@ mod tests {
let path = format!("{}/{}_{}.ec{:02}", dir, collection, vid.0, sid);
std::fs::write(&path, b"shard data nonempty").unwrap();
}
std::fs::write(
format!("{}/{}_{}.ecx", dir, collection, vid.0),
vec![0u8; 20],
)
.unwrap();
std::fs::write(format!("{}/{}_{}.ecx", dir, collection, vid.0), vec![0u8; 20])
.unwrap();
std::fs::write(format!("{}/{}_{}.ecj", dir, collection, vid.0), b"").unwrap();
std::fs::write(
format!("{}/{}_{}.vif", dir, collection, vid.0),
@@ -30,7 +30,6 @@ use crate::pb::volume_server_pb::{
ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums, EcShardConfig,
};
use crate::storage::erasure_coding::ec_shard::MAX_SHARD_COUNT;
use crate::storage::io::read_exact_at;
use crate::storage::needle::crc::CRC;
/// Canonical extension for the checksum sidecar. Generation 0 (legacy/fresh
@@ -538,7 +537,7 @@ pub fn verify_shard_blocks(
break;
}
let to_read = to_read as usize;
read_exact_at(f, &mut buf[..to_read], offset as u64)?;
read_full_at(f, &mut buf[..to_read], offset as u64)?;
if CRC::new(&buf[..to_read]).0 != *want_crc {
mismatched.push(i);
}
@@ -547,6 +546,33 @@ pub fn verify_shard_blocks(
Ok(mismatched)
}
/// Reads exactly `buf.len()` bytes from `f` at `offset`, erroring on early EOF.
fn read_full_at(f: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
let mut total = 0usize;
while total < buf.len() {
#[cfg(unix)]
let n = {
use std::os::unix::fs::FileExt;
f.read_at(&mut buf[total..], offset + total as u64)?
};
#[cfg(not(unix))]
let n = {
use std::io::{Read, Seek, SeekFrom};
let mut fc = f.try_clone()?;
fc.seek(SeekFrom::Start(offset + total as u64))?;
fc.read(&mut buf[total..])?
};
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"short read on shard block",
));
}
total += n;
}
Ok(())
}
/// Builds the `EcShardConfig` proto for the given layout. The bitrot sidecar
/// carries its own top-level encode_uuid, so the nested config leaves it empty.
pub fn ec_shard_config(data_shards: u32, parity_shards: u32, block_size: i64) -> EcShardConfig {
@@ -601,10 +627,7 @@ mod tests {
save_bitrot_sidecar(path, &prot).unwrap();
let bytes = std::fs::read(path).unwrap();
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
assert_eq!(
hex, CANONICAL_HEX,
"Rust .ecsum bytes drifted from the Go canonical form"
);
assert_eq!(hex, CANONICAL_HEX, "Rust .ecsum bytes drifted from the Go canonical form");
let _ = std::fs::remove_file(path);
}
@@ -639,11 +662,7 @@ mod tests {
format!("{}.ecsum.v1", base),
format!("{}.ecsum.v7", base),
] {
assert!(
!std::path::Path::new(&p).exists(),
"{} should be removed",
p
);
assert!(!std::path::Path::new(&p).exists(), "{} should be removed", p);
}
assert!(std::path::Path::new(&keep_shard).exists());
assert!(std::path::Path::new(&keep_other_vid).exists());
@@ -662,9 +681,7 @@ mod tests {
assert!(!is_pow2_multiple_of_1mib(1 << 19)); // 512 KiB, too small
assert!(!is_pow2_multiple_of_1mib(3 << 20)); // 3 MiB, not pow2
assert!(!is_pow2_multiple_of_1mib(128 * 1024 * 1024)); // pow2 but > MAX_BITROT_BLOCK_SIZE
assert!(!is_pow2_multiple_of_1mib(
DEFAULT_BITROT_BLOCK_SIZE as u32 + 1
));
assert!(!is_pow2_multiple_of_1mib(DEFAULT_BITROT_BLOCK_SIZE as u32 + 1));
}
#[test]
@@ -718,7 +735,12 @@ mod tests {
#[test]
fn test_save_load_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("vol.ecsum").to_str().unwrap().to_string();
let path = tmp
.path()
.join("vol.ecsum")
.to_str()
.unwrap()
.to_string();
let mut builder = ShardChecksumBuilder::new(DEFAULT_BITROT_BLOCK_SIZE as i64);
builder.write(b"hello world");
@@ -879,7 +901,8 @@ mod tests {
assert_eq!(resolve_status(&notfound, 0, 10, 4), BitrotStatus::Off);
// Integrity failure => Invalid.
let bad: Result<EcBitrotProtection, BitrotLoadError> = Err(BitrotLoadError::BadMagic(0));
let bad: Result<EcBitrotProtection, BitrotLoadError> =
Err(BitrotLoadError::BadMagic(0));
assert_eq!(resolve_status(&bad, 0, 10, 4), BitrotStatus::Invalid);
// Generation mismatch => Off.
@@ -67,45 +67,73 @@ pub fn find_dat_file_size_with_dirs(
Ok(dat_size)
}
/// What it takes to rebuild a volume's .dat from its EC data shards.
/// Reconstruct a .dat file from EC data shards.
///
/// Reads from .ec00-.ec09 and writes a new .dat file. All data shards
/// must live in `dir`. For the cross-disk reconciled layout where
/// shards are split across multiple data dirs of the same node, use
/// [`write_dat_file_from_shards_with_dirs`] instead.
#[expect(clippy::too_many_arguments)]
pub fn write_dat_file_from_shards(
dir: &str,
collection: &str,
volume_id: VolumeId,
dat_file_size: i64,
encoded_dat_file_size: i64,
data_shards: usize,
large_block_size: usize,
small_block_size: usize,
) -> io::Result<()> {
let dirs: Vec<String> = (0..data_shards).map(|_| dir.to_string()).collect();
write_dat_file_from_shards_with_dirs(
dir,
collection,
volume_id,
dat_file_size,
encoded_dat_file_size,
data_shards,
&dirs,
large_block_size,
small_block_size,
)
}
/// Reconstruct a .dat file from EC data shards, taking the source
/// directory for each shard separately.
///
/// `dat_dir` is where the produced `.dat` is written. `shard_dirs[i]`
/// is the directory holding shard `i`. For the simple "all shards in
/// one dir" case both can be the same value.
///
/// Mirrors Go's `WriteDatFile(baseFileName, datFileSize,
/// encodedDatFileSize, shardFileNames)` shape — Go passes per-shard
/// paths so a reconciled volume with shards split across disks of the
/// same volume server can still be decoded back to a regular .dat
/// (seaweedfs/seaweedfs#9252).
#[derive(Clone, Copy, Debug)]
pub struct DatRebuild<'a> {
/// Where the produced `.dat` is written.
pub dat_dir: &'a str,
pub collection: &'a str,
pub volume_id: VolumeId,
/// The number of bytes to write, i.e. the live data extent from
/// [`find_dat_file_size`].
pub dat_file_size: i64,
/// The .dat size at encode time, which fixed the shard block layout:
/// deletions can move the live extent below the large-block row
/// boundary, and deriving the layout from the shrunk extent would read
/// the shards in the wrong block order. Zero when the .vif does not
/// record the encode-time size; the layout is then inferred from the
/// shard size.
pub encoded_dat_file_size: i64,
pub data_shards: usize,
/// `shard_dirs[i]` is the directory holding shard `i`. `None` means every
/// data shard sits in `dat_dir`.
pub shard_dirs: Option<&'a [String]>,
/// The volume's shard block layout, e.g. `EcVolume::large_block_size()`
/// / `small_block_size()` from its .vif EC config.
pub large_block_size: usize,
pub small_block_size: usize,
}
/// Reconstruct a .dat file from EC data shards.
///
/// Reads from .ec00-.ec09 and writes a new .dat file, from one directory or
/// from the per-shard directories of a cross-disk reconciled volume.
pub fn write_dat_file_from_shards(spec: &DatRebuild<'_>) -> io::Result<()> {
let DatRebuild {
/// `dat_file_size` is the number of bytes to write, i.e. the live data
/// extent from [`find_dat_file_size`]. `encoded_dat_file_size` is the
/// .dat size at encode time, which fixed the shard block layout:
/// deletions can move the live extent below the large-block row
/// boundary, and deriving the layout from the shrunk extent would read
/// the shards in the wrong block order. Pass zero when the .vif does
/// not record the encode-time size to infer the layout from the shard
/// size. `large_block_size`/`small_block_size` are the volume's shard
/// block layout, e.g. `EcVolume::large_block_size()` /
/// `small_block_size()` from its .vif EC config.
#[expect(clippy::too_many_arguments)]
pub fn write_dat_file_from_shards_with_dirs(
dat_dir: &str,
collection: &str,
volume_id: VolumeId,
dat_file_size: i64,
encoded_dat_file_size: i64,
data_shards: usize,
shard_dirs: &[String],
large_block_size: usize,
small_block_size: usize,
) -> io::Result<()> {
write_dat_file(
dat_dir,
collection,
volume_id,
@@ -115,15 +143,21 @@ pub fn write_dat_file_from_shards(spec: &DatRebuild<'_>) -> io::Result<()> {
shard_dirs,
large_block_size,
small_block_size,
} = *spec;
let same_dir: Vec<String>;
let shard_dirs: &[String] = match shard_dirs {
Some(dirs) => dirs,
None => {
same_dir = vec![dat_dir.to_string(); data_shards];
&same_dir
}
};
)
}
#[expect(clippy::too_many_arguments)]
fn write_dat_file(
dat_dir: &str,
collection: &str,
volume_id: VolumeId,
dat_file_size: i64,
encoded_dat_file_size: i64,
data_shards: usize,
shard_dirs: &[String],
large_block_size: usize,
small_block_size: usize,
) -> io::Result<()> {
if data_shards == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -336,7 +370,7 @@ mod tests {
use crate::storage::erasure_coding::ec_encoder;
use crate::storage::needle::needle::Needle;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{Volume, VolumeSpec};
use crate::storage::volume::Volume;
use tempfile::TempDir;
#[test]
@@ -348,9 +382,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
@@ -390,17 +428,16 @@ mod tests {
std::fs::remove_file(format!("{}/1.idx", dir)).unwrap();
// Reconstruct from EC shards
write_dat_file_from_shards(&DatRebuild {
dat_dir: dir,
collection: "",
volume_id: VolumeId(1),
dat_file_size: original_dat_size as i64,
encoded_dat_file_size: original_dat_size as i64,
write_dat_file_from_shards(
dir,
"",
VolumeId(1),
original_dat_size as i64,
original_dat_size as i64,
data_shards,
shard_dirs: None,
large_block_size: block_size as usize,
small_block_size: block_size as usize,
})
block_size as usize,
block_size as usize,
)
.unwrap();
write_idx_file_from_ec_index(dir, "", VolumeId(1)).unwrap();
@@ -420,9 +457,13 @@ mod tests {
let v2 = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
@@ -442,29 +483,29 @@ mod tests {
let dir = tmp.path().to_str().unwrap();
// No shard files exist, so de-striping must fail and publish nothing:
// neither the final .dat nor a partial .dat.tmp may remain.
let res = write_dat_file_from_shards(&DatRebuild {
dat_dir: dir,
collection: "",
volume_id: VolumeId(7),
dat_file_size: 100,
encoded_dat_file_size: 100,
data_shards: 10,
shard_dirs: None,
large_block_size: ERASURE_CODING_LARGE_BLOCK_SIZE,
small_block_size: ERASURE_CODING_SMALL_BLOCK_SIZE,
});
let res = write_dat_file_from_shards(
dir,
"",
VolumeId(7),
100,
100,
10,
ERASURE_CODING_LARGE_BLOCK_SIZE,
ERASURE_CODING_SMALL_BLOCK_SIZE,
);
assert!(res.is_err());
assert!(!std::path::Path::new(&format!("{}/7.dat", dir)).exists());
assert!(!std::path::Path::new(&format!("{}/7.dat.tmp", dir)).exists());
}
// Decoding when .vif does not record the encode-time size: the layout is
// inferred from the shard size, except when that is an exact large-block
// multiple and the live extent reaches the ambiguous region.
#[test]
fn test_write_dat_file_fallback_layout() {
use crate::storage::erasure_coding::ec_bitrot::{
DEFAULT_BITROT_BLOCK_SIZE, ShardChecksumBuilder,
ShardChecksumBuilder, DEFAULT_BITROT_BLOCK_SIZE,
};
use reed_solomon_erasure::galois_8::ReedSolomon;
@@ -502,13 +543,11 @@ mod tests {
&rs,
&mut shards,
&mut builders,
ec_encoder::EcEncodeLayout {
data_shards,
parity_shards,
buffer_size: SMALL,
large_block_size: LARGE,
small_block_size: SMALL,
},
data_shards,
parity_shards,
SMALL,
LARGE,
SMALL,
)
.unwrap();
for shard in &mut shards {
@@ -526,17 +565,7 @@ mod tests {
-> io::Result<Vec<u8>> {
let out = format!("{}/{}", dir, sub);
std::fs::create_dir_all(&out).unwrap();
write_dat_file_from_shards(&DatRebuild {
dat_dir: &out,
collection: "",
volume_id: VolumeId(1),
dat_file_size: live,
encoded_dat_file_size: encoded,
data_shards: 10,
shard_dirs: Some(shard_dirs),
large_block_size: LARGE,
small_block_size: SMALL,
})?;
write_dat_file(&out, "", VolumeId(1), live, encoded, 10, shard_dirs, LARGE, SMALL)?;
Ok(std::fs::read(format!("{}/1.dat", out)).unwrap())
};
@@ -550,20 +579,14 @@ mod tests {
// each shard exactly one large block, indistinguishable from one large row
let (dir, shard_dirs, _) = encode("ambig1", large_row_size - 1);
let err = decode_to(&dir, "out", large_row_size / 2, 0, &shard_dirs).unwrap_err();
assert!(
err.to_string()
.contains("does not identify the block layout")
);
assert!(err.to_string().contains("does not identify the block layout"));
// two-row equivalent: decoding within the agreed prefix still works
let (dir, shard_dirs, original) = encode("ambig2", 2 * large_row_size - 1);
let decoded = decode_to(&dir, "outa", large_row_size, 0, &shard_dirs).unwrap();
assert_eq!(&original[..large_row_size as usize], &decoded[..]);
let err = decode_to(&dir, "outb", large_row_size + 1, 0, &shard_dirs).unwrap_err();
assert!(
err.to_string()
.contains("does not identify the block layout")
);
assert!(err.to_string().contains("does not identify the block layout"));
}
// Decoding after deletions moved the live extent below the large-block row
@@ -572,7 +595,7 @@ mod tests {
#[test]
fn test_write_dat_file_after_tail_deletion() {
use crate::storage::erasure_coding::ec_bitrot::{
DEFAULT_BITROT_BLOCK_SIZE, ShardChecksumBuilder,
ShardChecksumBuilder, DEFAULT_BITROT_BLOCK_SIZE,
};
use reed_solomon_erasure::galois_8::ReedSolomon;
@@ -612,13 +635,11 @@ mod tests {
&rs,
&mut shards,
&mut builders,
ec_encoder::EcEncodeLayout {
data_shards,
parity_shards,
buffer_size: SMALL,
large_block_size: LARGE,
small_block_size: SMALL,
},
data_shards,
parity_shards,
SMALL,
LARGE,
SMALL,
)
.unwrap();
for shard in &mut shards {
@@ -634,17 +655,17 @@ mod tests {
std::fs::create_dir(&out_dir).unwrap();
let out = out_dir.to_str().unwrap();
let decode = |live_size: i64, encoded_size: i64| -> Vec<u8> {
write_dat_file_from_shards(&DatRebuild {
dat_dir: out,
collection: "",
volume_id: VolumeId(1),
dat_file_size: live_size,
encoded_dat_file_size: encoded_size,
write_dat_file(
out,
"",
VolumeId(1),
live_size,
encoded_size,
data_shards,
shard_dirs: Some(&shard_dirs),
large_block_size: LARGE,
small_block_size: SMALL,
})
&shard_dirs,
LARGE,
SMALL,
)
.unwrap();
let path = format!("{}/1.dat", out);
let decoded = std::fs::read(&path).unwrap();
@@ -679,19 +700,17 @@ mod tests {
assert_ne!(&original[..(large_row_size / 2) as usize], &control[..]);
// the live extent can never exceed the encode-time size
assert!(
write_dat_file_from_shards(&DatRebuild {
dat_dir: out,
collection: "",
volume_id: VolumeId(1),
dat_file_size: dat_size + 1,
encoded_dat_file_size: dat_size,
data_shards,
shard_dirs: Some(&shard_dirs),
large_block_size: LARGE,
small_block_size: SMALL,
})
.is_err()
);
assert!(write_dat_file(
out,
"",
VolumeId(1),
dat_size + 1,
dat_size,
data_shards,
&shard_dirs,
LARGE,
SMALL,
)
.is_err());
}
}
@@ -5,12 +5,16 @@
use std::fs::File;
use std::io;
#[cfg(not(unix))]
use std::io::{Read, Seek, SeekFrom};
use reed_solomon_erasure::galois_8::ReedSolomon;
use crate::pb::volume_server_pb::{ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums};
use crate::pb::volume_server_pb::{
ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums,
};
use crate::storage::erasure_coding::ec_bitrot::{
self, DEFAULT_BITROT_BLOCK_SIZE, ShardChecksumBuilder,
self, ShardChecksumBuilder, DEFAULT_BITROT_BLOCK_SIZE,
};
use crate::storage::erasure_coding::ec_shard::*;
use crate::storage::idx;
@@ -73,13 +77,11 @@ pub fn write_ec_files(
&rs,
&mut shards,
&mut builders,
EcEncodeLayout {
data_shards,
parity_shards,
buffer_size: ENCODE_BUFFER_SIZE,
large_block_size: block_size as usize,
small_block_size: block_size as usize,
},
data_shards,
parity_shards,
ENCODE_BUFFER_SIZE,
block_size as usize,
block_size as usize,
)?;
// Close all shards
@@ -425,23 +427,22 @@ pub(crate) fn write_sorted_ecx_from_idx(idx_path: &str, ecx_path: &str) -> io::R
// Read all idx entries
let mut idx_file = File::open(idx_path)?;
let mut last: std::collections::HashMap<NeedleId, (Offset, Size)> =
std::collections::HashMap::new();
let mut entries: Vec<(NeedleId, Offset, Size)> = Vec::new();
idx::walk_index_file(&mut idx_file, 0, |key, offset, size| {
last.insert(key, (offset, size));
entries.push((key, offset, size));
Ok(())
})?;
let mut entries: Vec<(NeedleId, Offset, Size)> = last
.into_iter()
.filter_map(|(key, (offset, size))| {
if size.is_deleted() || offset.is_zero() {
None
} else {
Some((key, offset, size))
}
})
.collect();
entries.sort_by_key(|&(key, _o, _s)| key);
// Sort by NeedleId, then by actual offset so later entries come last
entries.sort_by_key(|&(key, offset, _)| (key, offset.to_actual_offset()));
// Remove duplicates (keep last/latest entry for each key).
// dedup_by_key keeps the first in each run, so we reverse first,
// dedup, then reverse back.
entries.reverse();
entries.dedup_by_key(|entry| entry.0);
entries.reverse();
// Write sorted entries to .ecx
let mut ecx_file = File::create(ecx_path)?;
@@ -582,8 +583,7 @@ pub fn rebuild_ecx_file(
}
let cookie = Cookie::from_bytes(&header_buf[..COOKIE_SIZE]);
let needle_id =
NeedleId::from_bytes(&header_buf[COOKIE_SIZE..COOKIE_SIZE + NEEDLE_ID_SIZE]);
let needle_id = NeedleId::from_bytes(&header_buf[COOKIE_SIZE..COOKIE_SIZE + NEEDLE_ID_SIZE]);
let size = Size::from_bytes(&header_buf[COOKIE_SIZE + NEEDLE_ID_SIZE..header_size]);
// Validate: stop if we hit zero cookie+id (end of data)
@@ -702,50 +702,30 @@ fn read_from_data_shards(
/// the uniform block is.
const ENCODE_BUFFER_SIZE: usize = 256 * 1024;
/// Shape of one encode run: the Reed-Solomon split and the block sizes that
/// fix where every byte of the .dat lands in the shards. Mirrors Go's
/// `ECContext`. `buffer_size` must divide both block sizes.
#[derive(Clone, Copy, Debug)]
pub(crate) struct EcEncodeLayout {
pub(crate) data_shards: usize,
pub(crate) parity_shards: usize,
/// Bytes of each shard's block handled per sub-batch; bounds memory at
/// `total_shards * buffer_size` however large the blocks are.
pub(crate) buffer_size: usize,
pub(crate) large_block_size: usize,
pub(crate) small_block_size: usize,
}
/// Encode the .dat file data into shard files.
///
/// Uses a two-phase approach matching Go's ec_encoder.go:
/// 1. Process as many large blocks as possible
/// 2. Process remaining data with small blocks
///
/// `buffer_size` must divide both block sizes.
#[expect(clippy::too_many_arguments)]
pub(crate) fn encode_dat_file(
dat_file: &File,
dat_size: i64,
rs: &ReedSolomon,
shards: &mut [EcVolumeShard],
builders: &mut [ShardChecksumBuilder],
layout: EcEncodeLayout,
data_shards: usize,
parity_shards: usize,
buffer_size: usize,
large_block_size: usize,
small_block_size: usize,
) -> io::Result<()> {
let EcEncodeLayout {
data_shards,
parity_shards,
buffer_size,
large_block_size,
small_block_size,
} = layout;
let total_shards = data_shards + parity_shards;
let mut buffers: Vec<Vec<u8>> = (0..total_shards).map(|_| vec![0u8; buffer_size]).collect();
let mut run = EncodeRun {
dat_file,
rs,
buffers: &mut buffers,
shards,
builders,
data_shards,
};
let mut buffers: Vec<Vec<u8>> = (0..total_shards)
.map(|_| vec![0u8; buffer_size])
.collect();
let mut remaining = dat_size;
let mut offset: u64 = 0;
@@ -754,7 +734,16 @@ pub(crate) fn encode_dat_file(
let large_row_size = large_block_size * data_shards;
while remaining >= large_row_size as i64 {
run.encode_row(offset, large_block_size)?;
encode_data(
dat_file,
offset,
large_block_size,
rs,
&mut buffers,
shards,
builders,
data_shards,
)?;
offset += large_row_size as u64;
remaining -= large_row_size as i64;
}
@@ -764,7 +753,16 @@ pub(crate) fn encode_dat_file(
while remaining > 0 {
let to_process = remaining.min(small_row_size as i64);
run.encode_row(offset, small_block_size)?;
encode_data(
dat_file,
offset,
small_block_size,
rs,
&mut buffers,
shards,
builders,
data_shards,
)?;
offset += to_process as u64;
remaining -= to_process;
}
@@ -772,73 +770,96 @@ pub(crate) fn encode_dat_file(
Ok(())
}
/// Everything one encode run streams through: the source .dat, the codec, a
/// buffer per shard, and the per-shard file and checksum sinks.
struct EncodeRun<'a> {
dat_file: &'a File,
rs: &'a ReedSolomon,
buffers: &'a mut [Vec<u8>],
shards: &'a mut [EcVolumeShard],
builders: &'a mut [ShardChecksumBuilder],
/// Encode one row of blocks, streaming it in ENCODE_BUFFER_SIZE sub-batches so
/// arbitrarily large blocks never require block-sized allocations. Mirrors
/// Go's encodeData.
#[expect(clippy::too_many_arguments)]
fn encode_data(
dat_file: &File,
row_offset: u64,
block_size: usize,
rs: &ReedSolomon,
buffers: &mut [Vec<u8>],
shards: &mut [EcVolumeShard],
builders: &mut [ShardChecksumBuilder],
data_shards: usize,
) -> io::Result<()> {
let buffer_size = buffers[0].len();
if !block_size.is_multiple_of(buffer_size) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unexpected block size {} buffer size {}",
block_size, buffer_size
),
));
}
let batch_count = block_size / buffer_size;
for b in 0..batch_count {
encode_one_batch(
dat_file,
row_offset + (b * buffer_size) as u64,
block_size,
rs,
buffers,
shards,
builders,
data_shards,
)?;
}
Ok(())
}
impl EncodeRun<'_> {
/// Encode one row of blocks, streaming it in ENCODE_BUFFER_SIZE sub-batches
/// so arbitrarily large blocks never require block-sized allocations.
/// Mirrors Go's encodeData.
fn encode_row(&mut self, row_offset: u64, block_size: usize) -> io::Result<()> {
let buffer_size = self.buffers[0].len();
if !block_size.is_multiple_of(buffer_size) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unexpected block size {} buffer size {}",
block_size, buffer_size
),
));
}
let batch_count = block_size / buffer_size;
for b in 0..batch_count {
self.encode_one_batch(row_offset + (b * buffer_size) as u64, block_size)?;
}
Ok(())
/// Encode one sub-batch: the same buffer-sized slice of every shard's block in
/// this row. Mirrors Go's encodeDataOneBatch.
#[expect(clippy::too_many_arguments)]
fn encode_one_batch(
dat_file: &File,
offset: u64,
block_size: usize,
rs: &ReedSolomon,
buffers: &mut [Vec<u8>],
shards: &mut [EcVolumeShard],
builders: &mut [ShardChecksumBuilder],
data_shards: usize,
) -> io::Result<()> {
// Read data shards from the .dat file, zero-filling past EOF — the buffers
// are reused across batches, so the tail must be cleared explicitly.
for (i, buf) in buffers[..data_shards].iter_mut().enumerate() {
let read_offset = offset + (i * block_size) as u64;
let n = read_at_most(dat_file, buf, read_offset)?;
buf[n..].fill(0);
}
/// Encode one sub-batch: the same buffer-sized slice of every shard's block
/// in this row. Mirrors Go's encodeDataOneBatch.
fn encode_one_batch(&mut self, offset: u64, block_size: usize) -> io::Result<()> {
// Read data shards from the .dat file, zero-filling past EOF — the
// buffers are reused across batches, so the tail must be cleared
// explicitly.
for (i, buf) in self.buffers[..self.data_shards].iter_mut().enumerate() {
let read_offset = offset + (i * block_size) as u64;
let n = read_at_most(self.dat_file, buf, read_offset)?;
buf[n..].fill(0);
}
// Encode parity shards
rs.encode(&mut *buffers)
.map_err(|e| io::Error::other(format!("reed-solomon encode: {:?}", e)))?;
// Encode parity shards
self.rs
.encode(&mut *self.buffers)
.map_err(|e| io::Error::other(format!("reed-solomon encode: {:?}", e)))?;
// Write all shard buffers to files and feed the same bytes to each
// shard's bitrot checksum builder, keeping covered_size == on-disk
// length.
for (i, buf) in self.buffers.iter().enumerate() {
self.shards[i].write_all(buf)?;
self.builders[i].write(buf);
}
Ok(())
// Write all shard buffers to files and feed the same bytes to each
// shard's bitrot checksum builder, keeping covered_size == on-disk length.
for (i, buf) in buffers.iter().enumerate() {
shards[i].write_all(buf)?;
builders[i].write(buf);
}
Ok(())
}
/// Read into `buf` at `offset` until it is full or EOF; returns bytes read.
fn read_at_most(dat_file: &File, buf: &mut [u8], offset: u64) -> io::Result<usize> {
let mut n = 0;
while n < buf.len() {
let r = crate::storage::io::read_at(dat_file, &mut buf[n..], offset + n as u64)?;
#[cfg(unix)]
let r = {
use std::os::unix::fs::FileExt;
dat_file.read_at(&mut buf[n..], offset + n as u64)?
};
#[cfg(not(unix))]
let r = {
let mut f = dat_file.try_clone()?;
f.seek(SeekFrom::Start(offset + n as u64))?;
f.read(&mut buf[n..])?
};
if r == 0 {
break;
}
@@ -852,7 +873,7 @@ mod tests {
use super::*;
use crate::storage::needle::needle::Needle;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{Volume, VolumeSpec};
use crate::storage::volume::Volume;
use tempfile::TempDir;
#[test]
@@ -864,9 +885,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
@@ -911,9 +936,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
for i in 1..=n {
@@ -986,9 +1015,13 @@ mod tests {
let mut v = Volume::new(
&dir,
&dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
for i in 1..=20 {
@@ -1020,10 +1053,7 @@ mod tests {
let victim = format!("{}/1.ec03", dir);
let full = std::fs::metadata(&victim).unwrap().len();
assert!(full > 0, "encoded shard should be non-empty");
let f = std::fs::OpenOptions::new()
.write(true)
.open(&victim)
.unwrap();
let f = std::fs::OpenOptions::new().write(true).open(&victim).unwrap();
f.set_len(full / 2).unwrap();
drop(f);
@@ -1177,15 +1207,19 @@ mod tests {
#[test]
fn test_rebuild_ecx_file_uniform_layout() {
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{Volume, VolumeSpec};
use crate::storage::volume::Volume;
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap().to_string();
let mut v = Volume::new(
&dir,
&dir,
"",
VolumeId(2),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
for i in 1u64..=12 {
@@ -1215,10 +1249,7 @@ mod tests {
rebuild_ecx_file(&dir, "", VolumeId(2), 10, block_size, 0, &[]).unwrap();
let rebuilt = std::fs::read(&ecx_path).unwrap();
assert_eq!(
canonical, rebuilt,
"rebuilt .ecx must match the encode-time .ecx"
);
assert_eq!(canonical, rebuilt, "rebuilt .ecx must match the encode-time .ecx");
}
// A truncated data shard must FAIL the .ecx rebuild, not publish the
@@ -1226,15 +1257,19 @@ mod tests {
#[test]
fn test_rebuild_ecx_file_fails_on_truncated_shard() {
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::{Volume, VolumeSpec};
use crate::storage::volume::Volume;
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap().to_string();
let mut v = Volume::new(
&dir,
&dir,
"",
VolumeId(3),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
for i in 1u64..=12 {
@@ -1332,9 +1367,13 @@ mod tests {
let mut v = Volume::new(
dat_dir,
idx_dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
@@ -1412,9 +1451,13 @@ mod tests {
let mut v = Volume::new(
dat_dir,
idx_dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
@@ -1446,9 +1489,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
vid,
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
for i in 1..=8 {
@@ -1539,86 +1586,4 @@ mod tests {
details
);
}
#[test]
fn test_encode_drops_tombstone_last_wins() {
use crate::storage::idx;
use crate::storage::types::{NeedleId, Offset, Size, TOMBSTONE_FILE_SIZE};
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let idx_path = format!("{}/t.idx", dir);
let ecx_path = format!("{}/t.ecx", dir);
let key = NeedleId(12345);
{
let mut f = std::fs::File::create(&idx_path).unwrap();
idx::write_index_entry(&mut f, key, Offset::from_actual_offset(1024), Size(100))
.unwrap();
idx::write_index_entry(&mut f, key, Offset::default(), TOMBSTONE_FILE_SIZE).unwrap();
}
super::write_sorted_ecx_from_idx(&idx_path, &ecx_path).unwrap();
let mut found = false;
{
let mut f = std::fs::File::open(&ecx_path).unwrap();
idx::walk_index_file(&mut f, 0, |k, _o, _s| {
if k == key {
found = true;
}
Ok(())
})
.unwrap();
}
assert!(!found, "tombstoned key must not appear in .ecx");
let idx2 = format!("{}/t2.idx", dir);
let ecx2 = format!("{}/t2.ecx", dir);
{
let mut f = std::fs::File::create(&idx2).unwrap();
idx::write_index_entry(&mut f, key, Offset::default(), TOMBSTONE_FILE_SIZE).unwrap();
idx::write_index_entry(&mut f, key, Offset::from_actual_offset(2048), Size(200))
.unwrap();
}
super::write_sorted_ecx_from_idx(&idx2, &ecx2).unwrap();
let mut found2 = false;
{
let mut f = std::fs::File::open(&ecx2).unwrap();
idx::walk_index_file(&mut f, 0, |k, o, s| {
if k == key {
found2 = true;
assert_eq!(o.to_actual_offset(), 2048);
assert_eq!(s, Size(200));
}
Ok(())
})
.unwrap();
}
assert!(found2, "re-created key must appear live");
// Zero offset with non-negative size is also a deletion: Go
// readNeedleMap (`if !offset.IsZero() && !size.IsDeleted() { Set }
// else { Delete }`) and CompactNeedleMap::load_from_idx both treat
// it as deleted. Encode must drop it too, or the .ecx live-map
// mismatches replay.
let idx3 = format!("{}/t3.idx", dir);
let ecx3 = format!("{}/t3.ecx", dir);
{
let mut f = std::fs::File::create(&idx3).unwrap();
idx::write_index_entry(&mut f, key, Offset::from_actual_offset(1024), Size(100))
.unwrap();
idx::write_index_entry(&mut f, key, Offset::default(), Size(0)).unwrap();
}
super::write_sorted_ecx_from_idx(&idx3, &ecx3).unwrap();
let mut found3 = false;
{
let mut f = std::fs::File::open(&ecx3).unwrap();
idx::walk_index_file(&mut f, 0, |k, _o, _s| {
if k == key {
found3 = true;
}
Ok(())
})
.unwrap();
}
assert!(
!found3,
"zero-offset row must not appear in .ecx even with non-negative size"
);
}
}
@@ -16,20 +16,6 @@ pub const ERASURE_CODING_SMALL_BLOCK_SIZE: usize = 1024 * 1024; // 1MB
pub type ShardId = u8;
/// Validate a wire shard id. `ShardId` is `u8` but only 0..MAX_SHARD_COUNT are valid.
/// Rejects 256 (would truncate to 0 and delete .ec00) and 270 (would alias 14).
pub fn shard_id_try_from(v: u32) -> Result<ShardId, String> {
if v < MAX_SHARD_COUNT as u32 {
Ok(v as ShardId)
} else {
Err(format!(
"invalid shard id {} (max {})",
v,
MAX_SHARD_COUNT - 1
))
}
}
/// A single erasure-coded shard file.
pub struct EcVolumeShard {
pub volume_id: VolumeId,
@@ -94,7 +80,21 @@ impl EcVolumeShard {
.as_ref()
.ok_or_else(|| io::Error::other("shard file not open"))?;
crate::storage::io::read_at(file, buf, offset)
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
file.read_at(buf, offset)
}
#[cfg(not(unix))]
{
use std::io::{Read, Seek, SeekFrom};
// File::read_at is unix-only; fall back to seek + read.
// We need a mutable reference for seek/read, so clone the handle.
let mut f = file.try_clone()?;
f.seek(SeekFrom::Start(offset))?;
f.read(buf)
}
}
/// Write data to the shard file (appends).
@@ -251,42 +251,4 @@ mod tests {
let shard = EcVolumeShard::new("/data", "", VolumeId(7), 13);
assert_eq!(shard.file_name(), "/data/7.ec13");
}
#[test]
fn test_shard_id_try_from_u32_rejects_overflow() {
use super::{MAX_SHARD_COUNT, shard_id_try_from};
assert_eq!(shard_id_try_from(0).unwrap(), 0u8);
assert_eq!(shard_id_try_from(14).unwrap(), 14u8);
assert_eq!(shard_id_try_from(31).unwrap(), 31u8);
assert!(shard_id_try_from(32).is_err());
assert!(shard_id_try_from(256).is_err());
assert!(shard_id_try_from(270).is_err());
assert!(shard_id_try_from(u32::MAX).is_err());
assert_eq!(MAX_SHARD_COUNT, 32);
}
#[test]
fn test_shard_batch_validation_is_atomic_rejects_without_partial_prefix() {
use super::shard_id_try_from;
// The mount/unmount handlers pre-validate the ENTIRE req.shard_ids into
// a Vec<ShardId> BEFORE acquiring the write lock or mutating any EC
// state. This test pins the validation half of that contract at the
// unit level: a batch like [0, 32] must fail as a whole, so by
// construction no validated prefix (e.g. shard 0) is ever applied.
// The handler-level tests below assert the no-state-change half.
let batch = vec![0u32, 32u32];
let validated: Result<Vec<_>, _> =
batch.iter().map(|&sid| shard_id_try_from(sid)).collect();
assert!(
validated.is_err(),
"batch {:?} must be rejected as a whole",
batch
);
// A fully-valid batch still validates cleanly.
let ok: Result<Vec<_>, _> = [0u32, 1u32, 13u32]
.iter()
.map(|&sid| shard_id_try_from(sid))
.collect();
assert_eq!(ok.unwrap(), vec![0u8, 1u8, 13u8]);
}
}
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,7 @@ pub mod ec_shard;
pub mod ec_volume;
pub use ec_shard::{
DATA_SHARDS_COUNT, EcVolumeShard, MAX_SHARD_COUNT, MIN_TOTAL_DISKS, PARITY_SHARDS_COUNT,
ShardId, TOTAL_SHARDS_COUNT,
EcVolumeShard, ShardId, DATA_SHARDS_COUNT, MAX_SHARD_COUNT, MIN_TOTAL_DISKS,
PARITY_SHARDS_COUNT, TOTAL_SHARDS_COUNT,
};
pub use ec_volume::EcVolume;
+2 -6
View File
@@ -57,7 +57,7 @@ pub fn check_index_file<R: Read + Seek>(
errs.push(format!("walk index file: {}", e));
}
entries.sort_by(|a, b| a.2.cmp(&b.2).then(a.3.0.cmp(&b.3.0)));
entries.sort_by(|a, b| a.2.cmp(&b.2).then(a.3 .0.cmp(&b.3 .0)));
// Offset-0 logical tombstones (remote-tier deletes) occupy no physical extent,
// so they cannot overlap anything — exclude them from the overlap check. They
@@ -213,11 +213,7 @@ mod tests {
let size = data.len() as i64;
let (count, errs) = check_index_file(&mut Cursor::new(data), size, Version(3));
assert_eq!(count, 2, "tombstone row is still counted: {:?}", errs);
assert!(
errs.is_empty(),
"offset-0 tombstone must not overlap: {:?}",
errs
);
assert!(errs.is_empty(), "offset-0 tombstone must not overlap: {:?}", errs);
}
#[test]
-142
View File
@@ -1,142 +0,0 @@
//! Positional file reads.
//!
//! Every read here is "these bytes at this offset", never "the next bytes".
//! The handles are shared — `.dat` and `.idx` descriptors are borrowed from
//! [`file_pool`](super::needle_map::file_pool), a mounted EC shard's handle is
//! duplicated into a scrub plan — so no caller may rely on a file position.
//!
//! On unix that is `pread(2)` through `std::os::unix::fs::FileExt`. On Windows
//! it is `seek_read`, which passes the offset through `OVERLAPPED`, so the read
//! itself is independent of the current cursor.
//!
//! What these helpers replace is `try_clone()` + `seek()` + `read()`. A
//! duplicated handle shares one kernel file offset with the original, so that
//! sequence is two syscalls against state another thread can move in between:
//! the seek positions the offset, a concurrent reader or an append moves it,
//! and the read returns bytes from somewhere else entirely. `seek_read` carries
//! its own offset in a single call, so there is no window.
//!
//! `seek_read` does still advance the cursor as a side effect — Windows updates
//! the file pointer even for an `OVERLAPPED` read — which nothing here relies
//! on. A caller that genuinely needs a private position must open the file
//! again rather than duplicate a handle; see `Volume::dat_scan_plan` in
//! [`storage::volume`](super::volume).
use std::fs::File;
use std::io;
/// Reads exactly `buf.len()` bytes from `file` starting at `offset`.
///
/// Fails with [`io::ErrorKind::UnexpectedEof`] if the file ends first.
pub(crate) fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
file.read_exact_at(buf, offset)?;
}
#[cfg(windows)]
{
use std::os::windows::fs::FileExt;
let mut filled = 0;
let mut at = offset;
while filled < buf.len() {
let n = match file.seek_read(&mut buf[filled..], at) {
Ok(n) => n,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
};
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF in seek_read",
));
}
filled += n;
at += n as u64;
}
}
#[cfg(not(any(unix, windows)))]
{
compile_error!("Platform not supported: only unix and windows are supported");
}
Ok(())
}
/// Reads up to `buf.len()` bytes from `file` starting at `offset`, returning
/// how many were read.
///
/// A short read — including `0` at or past end of file — is not an error; use
/// [`read_exact_at`] when the whole buffer must be filled.
pub(crate) fn read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<usize> {
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
file.read_at(buf, offset)
}
#[cfg(windows)]
{
use std::os::windows::fs::FileExt;
file.seek_read(buf, offset)
}
#[cfg(not(any(unix, windows)))]
{
compile_error!("Platform not supported: only unix and windows are supported");
}
}
#[cfg(test)]
mod tests {
use super::{read_at, read_exact_at};
use std::io::{ErrorKind, Write};
fn temp_file(bytes: &[u8]) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("temp file");
f.write_all(bytes).expect("write");
f.flush().expect("flush");
f
}
#[test]
fn read_exact_at_fills_the_whole_buffer() {
let f = temp_file(b"0123456789");
let mut buf = [0u8; 10];
read_exact_at(f.as_file(), &mut buf, 0).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn read_exact_at_reads_from_the_offset() {
let f = temp_file(b"0123456789");
let mut buf = [0u8; 4];
read_exact_at(f.as_file(), &mut buf, 3).expect("read");
assert_eq!(&buf, b"3456");
// The helper is positional: a second read at a lower offset sees the
// bytes at that offset, not wherever the first read left a cursor.
let mut again = [0u8; 4];
read_exact_at(f.as_file(), &mut again, 1).expect("read");
assert_eq!(&again, b"1234");
}
#[test]
fn read_exact_at_short_file_is_unexpected_eof() {
let f = temp_file(b"0123");
let mut buf = [0u8; 8];
let err = read_exact_at(f.as_file(), &mut buf, 0).expect_err("short file");
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
}
#[test]
fn read_at_allows_a_short_read_at_eof() {
let f = temp_file(b"0123456789");
let mut buf = [0u8; 8];
let n = read_at(f.as_file(), &mut buf, 6).expect("read");
assert_eq!(n, 4);
assert_eq!(&buf[..n], b"6789");
// Entirely past the end is zero bytes, not an error.
let n = read_at(f.as_file(), &mut buf, 10).expect("read");
assert_eq!(n, 0);
}
}
-202
View File
@@ -1,202 +0,0 @@
//! Consecutive storage-media error tracking shared by `Volume` and
//! `EcVolume`. Mirrors Go's `weed/storage/io_error.go`.
use std::io;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
/// Consecutive storage-media errors allowed before the volume is quarantined.
pub(crate) const IO_ERROR_TOLERANCE: i32 = 3;
/// Returns true for I/O errors that indicate faulty storage media, not
/// transient/network failures. On Unix this is EIO; on Windows it covers
/// ERROR_CRC and ERROR_IO_DEVICE, which the kernel returns for failing disks.
pub(crate) fn is_storage_io_error(e: &io::Error) -> bool {
#[cfg(unix)]
{
e.raw_os_error() == Some(libc::EIO)
}
#[cfg(windows)]
{
const ERROR_CRC: i32 = 23;
const ERROR_IO_DEVICE: i32 = 1117;
return e.raw_os_error() == Some(ERROR_CRC) || e.raw_os_error() == Some(ERROR_IO_DEVICE);
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
/// Consecutive storage-media error state for one volume. `quarantined` is
/// sticky: once set it survives later successful I/O and is lifted only by
/// `reset_io_error_state`.
#[derive(Default)]
pub(crate) struct IoErrorTracker {
last: Mutex<Option<String>>,
count: AtomicI32,
quarantined: AtomicBool,
}
impl IoErrorTracker {
/// `Some(e)` records a failure, `None` a success. Only storage-media
/// failures count; every other outcome clears the count and last error.
pub(crate) fn check_read_write_error(&self, err: Option<&io::Error>) {
if let Some(e) = err
&& is_storage_io_error(e)
{
self.count.fetch_add(1, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock() {
*guard = Some(e.to_string());
}
crate::metrics::STORAGE_IO_ERROR_COUNTER.inc();
return;
}
self.count.store(0, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock()
&& guard.is_some()
{
*guard = None;
}
}
/// The last recorded error, the consecutive count, and the quarantine flag.
pub(crate) fn get_io_error_state(&self) -> (Option<String>, i32, bool) {
let err = self.last.lock().ok().and_then(|g| g.clone());
let count = self.count.load(Ordering::Relaxed);
let quarantined = self.quarantined.load(Ordering::Relaxed);
(err, count, quarantined)
}
pub(crate) fn should_quarantine(&self) -> bool {
self.quarantined.load(Ordering::Relaxed)
|| self.count.load(Ordering::Relaxed) >= IO_ERROR_TOLERANCE
}
pub(crate) fn mark_io_quarantined(&self) {
self.quarantined.store(true, Ordering::Relaxed);
}
pub(crate) fn reset_io_error_state(&self) {
self.count.store(0, Ordering::Relaxed);
self.quarantined.store(false, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock() {
*guard = None;
}
}
#[cfg(test)]
pub(crate) fn set_last_io_error_for_test(&self, err: Option<&str>) {
if let Ok(mut guard) = self.last.lock() {
*guard = err.map(|value| value.to_string());
}
if err.is_some() {
self.count.store(IO_ERROR_TOLERANCE, Ordering::Relaxed);
} else {
self.count.store(0, Ordering::Relaxed);
}
}
}
// The tracker only reacts to errors `is_storage_io_error` recognises, which is
// nothing at all on a platform that is neither Unix nor Windows.
#[cfg(all(test, any(unix, windows)))]
mod tests {
use super::*;
/// An OS error the platform reports for failing storage media.
#[cfg(unix)]
fn media_error() -> io::Error {
io::Error::from_raw_os_error(libc::EIO)
}
/// An OS error the platform reports for failing storage media.
#[cfg(windows)]
fn media_error() -> io::Error {
const ERROR_IO_DEVICE: i32 = 1117;
io::Error::from_raw_os_error(ERROR_IO_DEVICE)
}
#[test]
fn check_read_write_error_counts_consecutive_media_errors() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let (last, count, quarantined) = tracker.get_io_error_state();
assert_eq!(last, Some(media_error().to_string()));
assert_eq!(count, 2);
assert!(!quarantined);
}
#[test]
fn success_clears_the_count_and_the_last_error() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(None);
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
#[test]
fn non_media_error_clears_the_count() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&io::Error::new(
io::ErrorKind::NotFound,
"no such file",
)));
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
#[test]
fn should_quarantine_only_once_the_tolerance_is_reached() {
let tracker = IoErrorTracker::default();
for _ in 1..IO_ERROR_TOLERANCE {
tracker.check_read_write_error(Some(&media_error()));
assert!(!tracker.should_quarantine());
}
tracker.check_read_write_error(Some(&media_error()));
assert!(tracker.should_quarantine());
}
#[test]
fn quarantine_survives_later_successful_io() {
let tracker = IoErrorTracker::default();
tracker.mark_io_quarantined();
tracker.check_read_write_error(None);
assert_eq!(tracker.get_io_error_state(), (None, 0, true));
assert!(tracker.should_quarantine());
}
#[test]
fn reset_io_error_state_lifts_the_quarantine() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.mark_io_quarantined();
tracker.reset_io_error_state();
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
assert!(!tracker.should_quarantine());
}
#[test]
fn test_helper_arms_a_sustained_error() {
let tracker = IoErrorTracker::default();
tracker.set_last_io_error_for_test(Some("input/output error"));
assert!(tracker.should_quarantine());
assert_eq!(
tracker.get_io_error_state(),
(
Some("input/output error".to_string()),
IO_ERROR_TOLERANCE,
false
)
);
tracker.set_last_io_error_for_test(None);
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
}
-2
View File
@@ -1,8 +1,6 @@
pub mod disk_location;
pub mod erasure_coding;
pub mod idx;
pub(crate) mod io;
pub(crate) mod io_error;
pub mod needle;
pub mod needle_map;
pub mod store;
+1 -4
View File
@@ -1,8 +1,5 @@
pub mod crc;
#[expect(
clippy::module_inception,
reason = "needle/needle.rs mirrors the Go package layout"
)]
#[expect(clippy::module_inception, reason = "needle/needle.rs mirrors the Go package layout")]
pub mod needle;
pub mod ttl;
+18 -99
View File
@@ -198,8 +198,8 @@ impl Needle {
/// the data payload from disk at all, matching Go's `ReadNeedleMeta`.
pub fn read_paged_meta(
&mut self,
header_bytes: &[u8], // first 20 bytes: NEEDLE_HEADER_SIZE + DATA_SIZE_SIZE
meta_bytes: &[u8], // tail: non-data body metadata + checksum + timestamp + padding
header_bytes: &[u8], // first 20 bytes: NEEDLE_HEADER_SIZE + DATA_SIZE_SIZE
meta_bytes: &[u8], // tail: non-data body metadata + checksum + timestamp + padding
offset: i64,
expected_size: Size,
version: Version,
@@ -581,19 +581,23 @@ impl Needle {
// ============================================================================
/// Compute padding to align needle to NEEDLE_PADDING_SIZE (8 bytes).
///
/// The sum is formed in i64: a size read from a corrupt header can sit near
/// `i32::MAX`, and adding the header, checksum and timestamp widths to it in
/// i32 would overflow (a panic with overflow checks, a wrapped padding
/// without). The result is at most NEEDLE_PADDING_SIZE, so it fits `Size`.
pub fn padding_length(needle_size: Size, version: Version) -> Size {
let fixed = if version == VERSION_3 {
NEEDLE_HEADER_SIZE + NEEDLE_CHECKSUM_SIZE + TIMESTAMP_SIZE
if version == VERSION_3 {
Size(
NEEDLE_PADDING_SIZE as i32
- ((NEEDLE_HEADER_SIZE as i32
+ needle_size.0
+ NEEDLE_CHECKSUM_SIZE as i32
+ TIMESTAMP_SIZE as i32)
% NEEDLE_PADDING_SIZE as i32),
)
} else {
NEEDLE_HEADER_SIZE + NEEDLE_CHECKSUM_SIZE
};
let unpadded = fixed as i64 + needle_size.0 as i64;
Size((NEEDLE_PADDING_SIZE as i64 - unpadded % NEEDLE_PADDING_SIZE as i64) as i32)
Size(
NEEDLE_PADDING_SIZE as i32
- ((NEEDLE_HEADER_SIZE as i32 + needle_size.0 + NEEDLE_CHECKSUM_SIZE as i32)
% NEEDLE_PADDING_SIZE as i32),
)
}
}
/// Body length = Size + Checksum + [Timestamp] + Padding.
@@ -615,30 +619,6 @@ pub fn get_actual_size(size: Size, version: Version) -> i64 {
NEEDLE_HEADER_SIZE as i64 + needle_body_length(size, version)
}
/// Validate a wire-supplied needle body size before any `as usize` cast.
/// Rejects negative/deleted sizes and bodies larger than the gRPC max message.
/// Size(0) is allowed: empty/anomalous entries and tombstones read as size 0
/// (actual_size = header+checksum+pad > 0, safe alloc, no wrap).
/// Transport cap only: storage paths must NOT use this cap — see volume.rs
/// guards (a >1GiB stored needle from a high-limit cluster must remain
/// readable/compaction-safe). Keep `get_actual_size` unchanged (it
/// intentionally returns negative for deleted index entries).
pub fn validate_wire_size(size: Size) -> Result<(), String> {
if size.0 < 0 {
return Err(format!("invalid needle size {}", size.0));
}
// Keep in sync with canonical `GRPC_MAX_MESSAGE_SIZE` in server/grpc_client.rs:10
// (duplicated here to avoid a storage->server import and prevent drift).
const WIRE_MAX_NEEDLE_SIZE: i32 = 1 << 30;
if size.0 > WIRE_MAX_NEEDLE_SIZE {
return Err(format!(
"needle size {} exceeds max {}",
size.0, WIRE_MAX_NEEDLE_SIZE
));
}
Ok(())
}
/// Read 5 bytes as a u64 (big-endian, zero-padded high bytes).
fn bytes_to_u64_5(bytes: &[u8]) -> u64 {
assert!(bytes.len() >= 5);
@@ -749,14 +729,6 @@ pub fn parse_needle_id_cookie(s: &str) -> Result<(NeedleId, Cookie), String> {
(s, None)
};
// Every length check and the split below are in BYTES, so a multi-byte
// character would let `split` land inside one and panic the slice. Hex is
// ASCII by definition; reject anything else up front, as Go's ParseUint
// does a step later.
if !hex_part.is_ascii() {
return Err("KeyHash must be ASCII hex.".to_string());
}
// Go: len(key_hash_string) <= CookieSize*2 => error (must be > 8 hex chars)
if hex_part.len() <= COOKIE_SIZE * 2 {
return Err("KeyHash is too short.".to_string());
@@ -798,9 +770,7 @@ pub fn parse_needle_id_cookie(s: &str) -> Result<(NeedleId, Cookie), String> {
#[derive(Debug, thiserror::Error)]
pub enum NeedleError {
#[error(
"size mismatch at offset {offset}: found id={id} size={found:?}, expected size={expected:?}"
)]
#[error("size mismatch at offset {offset}: found id={id} size={found:?}, expected size={expected:?}")]
SizeMismatch {
offset: i64,
id: NeedleId,
@@ -836,30 +806,6 @@ pub enum NeedleError {
mod tests {
use super::*;
/// A fid whose hex part carries multi-byte UTF-8 must be rejected, not
/// panic. `split` is a byte offset into `hex_part`; before the ASCII guard
/// `&hex_part[..split]` could land inside a character. `GET /3,ééééa` is
/// nine bytes, so it passes the length checks and splits at byte 1 —
/// halfway through the first `é`. Go's `ParseUint` just errors.
#[test]
fn parse_needle_id_cookie_rejects_non_ascii_instead_of_panicking() {
for s in ["ééééa", "ééééaaaaa", "0123456é9abc", "ééééa_1"] {
assert!(
parse_needle_id_cookie(s).is_err(),
"non-ASCII fid {:?} must be an error",
s
);
}
}
/// The ASCII guard must not change any accepted input.
#[test]
fn parse_needle_id_cookie_still_accepts_ascii_hex() {
let (id, cookie) = parse_needle_id_cookie("01637037d6").unwrap();
assert_eq!(id, NeedleId(0x01));
assert_eq!(cookie, Cookie(0x637037d6));
}
#[test]
fn test_parse_header() {
let mut buf = [0u8; NEEDLE_HEADER_SIZE];
@@ -977,21 +923,6 @@ mod tests {
}
}
#[test]
fn padding_length_does_not_overflow_on_a_corrupt_size() {
// A header read from a corrupt or truncated file can carry any i32
// size. The scanners bound it against the bytes left before sizing a
// buffer, but on a volume with more than 2 GiB left a size near
// i32::MAX passes that bound, so the padding arithmetic itself must
// not overflow. Overflow checks are on in test builds, so an i32 sum
// here would panic rather than wrap.
for version in [VERSION_2, VERSION_3] {
let padding = padding_length(Size(i32::MAX), version).0 as i64;
assert!((1..=NEEDLE_PADDING_SIZE as i64).contains(&padding));
assert_eq!(get_actual_size(Size(i32::MAX), version) % 8, 0);
}
}
#[test]
fn test_file_id_parse() {
let fid = FileId::parse("3,01637037d6").unwrap();
@@ -1036,16 +967,4 @@ mod tests {
assert_eq!(fid.key, NeedleId(0x123));
assert_eq!(fid.cookie, Cookie(0));
}
#[test]
fn test_validate_wire_size_boundaries() {
assert!(validate_wire_size(Size(-100)).is_err());
assert!(validate_wire_size(Size(-1)).is_err());
assert!(validate_wire_size(Size(0)).is_ok());
assert!(validate_wire_size(Size(1024)).is_ok());
assert!(validate_wire_size(Size(1)).is_ok());
assert!(validate_wire_size(Size(1 << 30)).is_ok());
assert!(validate_wire_size(Size((1 << 30) + 1)).is_err());
assert!(validate_wire_size(Size(i32::MAX)).is_err());
}
}
+8 -72
View File
@@ -80,12 +80,6 @@ impl TTL {
if s.is_empty() {
return Ok(TTL::EMPTY);
}
// The unit is read as the last BYTE and the count as everything before
// it, so a trailing multi-byte character would split inside itself and
// panic. A TTL is digits plus a one-letter unit; reject the rest.
if !s.is_ascii() {
return Err(format!("invalid TTL {:?}: must be ASCII", s));
}
let last_byte = s.as_bytes()[s.len() - 1];
let (num_str, unit_byte) = if last_byte.is_ascii_digit() {
// All digits — default to minutes (matching Go)
@@ -246,16 +240,6 @@ impl fmt::Display for TTL {
mod tests {
use super::*;
/// `?ttl=5%C3%A9` must be an error, not a panic. The unit is taken as the
/// last *byte*, so a trailing multi-byte character made `&s[..s.len()-1]`
/// split inside it.
#[test]
fn ttl_read_rejects_non_ascii_instead_of_panicking() {
for s in ["5é", "é", "3🦀", "12é"] {
assert!(TTL::read(s).is_err(), "non-ASCII TTL {:?} must error", s);
}
}
#[test]
fn test_ttl_parse() {
let ttl = TTL::read("3m").unwrap();
@@ -274,13 +258,7 @@ mod tests {
// 24h normalizes to 1d via fitTtlCount
let ttl = TTL::read("24h").unwrap();
assert_eq!(ttl.to_seconds(), 86400);
assert_eq!(
ttl,
TTL {
count: 1,
unit: TTL_UNIT_DAY
}
);
assert_eq!(ttl, TTL { count: 1, unit: TTL_UNIT_DAY });
}
#[test]
@@ -326,24 +304,12 @@ mod tests {
fn test_ttl_overflow_normalizes() {
// Go's ReadTTL calls fitTtlCount: 300m = 18000s = 5h (exact fit)
let ttl = TTL::read("300m").unwrap();
assert_eq!(
ttl,
TTL {
count: 5,
unit: TTL_UNIT_HOUR
}
);
assert_eq!(ttl, TTL { count: 5, unit: TTL_UNIT_HOUR });
// 256h = 921600s. Doesn't fit in hours (256 >= 256), doesn't fit exact in days.
// Second pass: 921600/86400 = 10 (truncated) < 256 -> 10d
let ttl = TTL::read("256h").unwrap();
assert_eq!(
ttl,
TTL {
count: 10,
unit: TTL_UNIT_DAY
}
);
assert_eq!(ttl, TTL { count: 10, unit: TTL_UNIT_DAY });
}
#[test]
@@ -351,49 +317,19 @@ mod tests {
// Go's ReadTTL calls fitTtlCount which normalizes to coarsest unit.
// 120m -> 2h, 7d -> 1w, 24h -> 1d.
let ttl = TTL::read("120m").unwrap();
assert_eq!(
ttl,
TTL {
count: 2,
unit: TTL_UNIT_HOUR
}
);
assert_eq!(ttl, TTL { count: 2, unit: TTL_UNIT_HOUR });
let ttl = TTL::read("7d").unwrap();
assert_eq!(
ttl,
TTL {
count: 1,
unit: TTL_UNIT_WEEK
}
);
assert_eq!(ttl, TTL { count: 1, unit: TTL_UNIT_WEEK });
let ttl = TTL::read("24h").unwrap();
assert_eq!(
ttl,
TTL {
count: 1,
unit: TTL_UNIT_DAY
}
);
assert_eq!(ttl, TTL { count: 1, unit: TTL_UNIT_DAY });
// Values that don't simplify stay as-is
let ttl = TTL::read("5d").unwrap();
assert_eq!(
ttl,
TTL {
count: 5,
unit: TTL_UNIT_DAY
}
);
assert_eq!(ttl, TTL { count: 5, unit: TTL_UNIT_DAY });
let ttl = TTL::read("3m").unwrap();
assert_eq!(
ttl,
TTL {
count: 3,
unit: TTL_UNIT_MINUTE
}
);
assert_eq!(ttl, TTL { count: 3, unit: TTL_UNIT_MINUTE });
}
}
+61 -103
View File
@@ -417,9 +417,9 @@ impl CompactNeedleMap {
}
/// Visit all entries in ascending order by needle ID.
pub fn ascending_visit<F, E>(&self, f: F) -> Result<(), E>
pub fn ascending_visit<F>(&self, f: F) -> Result<(), String>
where
F: FnMut(NeedleId, &NeedleValue) -> Result<(), E>,
F: FnMut(NeedleId, &NeedleValue) -> Result<(), String>,
{
self.map.ascending_visit(f)
}
@@ -607,14 +607,6 @@ impl RedbNeedleMap {
}
}
/// Test-only read of META `idx_size` through the live handle. See
/// [`test_support::live_meta_idx_size`] for why durability tests use
/// this instead of copying the open `.rdb`.
#[cfg(test)]
pub(crate) fn live_meta_idx_size(&self) -> Option<u64> {
self.read_idx_size_meta().unwrap()
}
/// Load from an .idx file, reusing an existing .rdb if it is consistent.
///
/// Strategy:
@@ -809,26 +801,23 @@ impl RedbNeedleMap {
}
#[cfg(feature = "redb-experimental-cursor")]
{
let mut cursor =
table
.upper_bound_mut(Bound::<u64>::Unbounded)
.map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("redb upper_bound_mut: {}", e),
)
})?;
let mut cursor = table
.upper_bound_mut(Bound::<u64>::Unbounded)
.map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("redb upper_bound_mut: {}", e),
)
})?;
for (key, nv) in &entries {
let key_u64: u64 = (*key).into();
let packed = pack_needle_value(nv);
cursor
.insert_before(key_u64, packed.as_slice())
.map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("redb insert_before: {}", e),
)
})?;
cursor.insert_before(key_u64, packed.as_slice()).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("redb insert_before: {}", e),
)
})?;
}
cursor.close().map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("redb cursor close: {}", e))
@@ -1125,12 +1114,18 @@ impl RedbNeedleMap {
let read_file = std::fs::OpenOptions::new()
.read(true)
.open(&idx_path)
.map_err(|e| io::Error::other(format!("reopen: open .idx {}: {}", idx_path, e)))?;
.map_err(|e| {
io::Error::other(format!("reopen: open .idx {}: {}", idx_path, e))
})?;
let actual_idx_size = read_file.metadata()?.len();
let mut reader = io::BufReader::new(read_file);
let reopened =
Self::load_from_idx(&self.rdb_path, &mut reader, self.version, self.cache_bytes)?;
let reopened = Self::load_from_idx(
&self.rdb_path,
&mut reader,
self.version,
self.cache_bytes,
)?;
// Preserve the append writer and the paths/version/cache; adopt the
// repaired database, metrics, and idx_file_offset from the reload.
@@ -1203,10 +1198,9 @@ impl RedbNeedleMap {
}
/// Visit all entries in ascending order by needle ID.
pub fn ascending_visit<F, E>(&self, mut f: F) -> Result<(), E>
pub fn ascending_visit<F>(&self, mut f: F) -> Result<(), String>
where
F: FnMut(NeedleId, &NeedleValue) -> Result<(), E>,
E: From<String>,
F: FnMut(NeedleId, &NeedleValue) -> Result<(), String>,
{
let txn = self
.db_or_err()
@@ -1444,10 +1438,9 @@ impl NeedleMap {
}
/// Visit all entries in ascending order by needle ID.
pub fn ascending_visit<F, E>(&self, f: F) -> Result<(), E>
pub fn ascending_visit<F>(&self, f: F) -> Result<(), String>
where
F: FnMut(NeedleId, &NeedleValue) -> Result<(), E>,
E: From<String>,
F: FnMut(NeedleId, &NeedleValue) -> Result<(), String>,
{
match self {
NeedleMap::InMemory(nm) => nm.ascending_visit(f),
@@ -1468,7 +1461,7 @@ impl NeedleMap {
// The visitor never fails, so neither can this.
let _ = nm.ascending_visit(|id, nv| {
entries.push((id, *nv));
Ok::<(), std::convert::Infallible>(())
Ok(())
});
Ok(entries)
}
@@ -1486,30 +1479,22 @@ impl NeedleMap {
pub(crate) mod test_support {
use super::*;
/// The `.idx` size in the map's META table, read through the live
/// handle.
///
/// The load path records the `.idx` size with `Durability::None`, and
/// every `put`/`delete` also commits non-durably, so before the first
/// checkpoint this is the load-time value (`Some(0)` for a fresh map) —
/// NOT the crash-durable `None` a copy of the open `.rdb` would show.
/// A live read is the only portable observation: redb 4.2.0 takes an
/// exclusive whole-file lock, which is advisory on Unix but mandatory
/// on Windows, so copying the open `.rdb` fails there with OS error 33.
///
/// It still pins the property under test: the only *durable* META
/// writer is `checkpoint`, so any value other than the load-time one
/// proves a checkpoint recorded progress — and the post-checkpoint
/// value equals the durable one, because checkpoints commit with
/// `Durability::Immediate`. What is lost vs the old copy: strict crash
/// fidelity — a hard crash pre-checkpoint would leave META absent
/// rather than `Some(0)` (loader-equivalent outcomes: full rebuild vs
/// replay-from-0, both correct). A clean close+reopen cannot recover
/// that distinction either: dropping the `Database` flushes pending
/// non-durable commits, so a reopened handle reads `Some(0)` just like
/// the live one.
pub(crate) fn live_meta_idx_size(nm: &RedbNeedleMap) -> Option<u64> {
nm.live_meta_idx_size()
/// The `.idx` size recorded in the durable state of the `.rdb` at
/// `rdb_path`, read from a copy taken while the map may still be open:
/// exactly what a crash would leave behind. `None` when nothing durable
/// has been recorded yet.
pub(crate) fn durable_idx_size(rdb_path: &Path) -> Option<u64> {
let copy = rdb_path.with_extension("crash-copy.rdb");
std::fs::copy(rdb_path, &copy).unwrap();
let db = Database::open(&copy).unwrap();
let txn = db.begin_read().unwrap();
let meta = txn.open_table(META_TABLE).ok()?;
let size = meta.get(META_IDX_SIZE).unwrap().map(|g| g.value());
drop(meta);
drop(txn);
drop(db);
let _ = std::fs::remove_file(&copy);
size
}
}
@@ -1840,7 +1825,7 @@ mod tests {
let mut live = 0u64;
nm.ascending_visit(|_, _| {
live += 1;
Ok::<(), String>(())
Ok(())
})
.unwrap();
assert_eq!(live, N - 1);
@@ -2053,7 +2038,7 @@ mod tests {
let mut visited = Vec::new();
nm.ascending_visit(|id, nv| {
visited.push((id, nv.size));
Ok::<(), String>(())
Ok(())
})
.unwrap();
@@ -2151,14 +2136,8 @@ mod tests {
// server opens one redb database per volume, so the process-wide
// ceiling is roughly (volumes x budget).
assert_eq!(NeedleMapKind::Redb.redb_cache_bytes(), 4 * 1024 * 1024);
assert_eq!(
NeedleMapKind::RedbMedium.redb_cache_bytes(),
8 * 1024 * 1024
);
assert_eq!(
NeedleMapKind::RedbLarge.redb_cache_bytes(),
16 * 1024 * 1024
);
assert_eq!(NeedleMapKind::RedbMedium.redb_cache_bytes(), 8 * 1024 * 1024);
assert_eq!(NeedleMapKind::RedbLarge.redb_cache_bytes(), 16 * 1024 * 1024);
}
#[test]
@@ -2187,7 +2166,7 @@ mod tests {
#[test]
fn test_redb_checkpoint_is_explicit_and_due_every_interval() {
use test_support::live_meta_idx_size;
use test_support::durable_idx_size;
// Every non-durable redb commit leaves bookkeeping behind until a
// durable one clears it, so a writable map asks for a checkpoint on
@@ -2197,12 +2176,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let (mut nm, db_path, _idx_path) = open_writable_redb(dir.path());
for i in 1..EXPECTED_INTERVAL {
nm.put(
NeedleId(i),
Offset::from_actual_offset((i * 8) as i64),
Size(1),
)
.unwrap();
nm.put(NeedleId(i), Offset::from_actual_offset((i * 8) as i64), Size(1))
.unwrap();
assert!(!nm.checkpoint_due(), "due after only {i} writes");
}
nm.put(
@@ -2212,29 +2187,20 @@ mod tests {
)
.unwrap();
assert!(nm.checkpoint_due());
// No checkpoint taken yet: META still holds the load-time .idx size.
// put() only commits non-durably, so the live value is unchanged.
assert_eq!(
live_meta_idx_size(&nm),
Some(0),
"put() must not record checkpoint progress"
);
assert_eq!(durable_idx_size(&db_path), None, "put() must not commit durably");
nm.checkpoint(true).unwrap();
assert!(!nm.checkpoint_due());
assert_eq!(
live_meta_idx_size(&nm),
durable_idx_size(&db_path),
Some(EXPECTED_INTERVAL * NEEDLE_MAP_ENTRY_SIZE as u64),
"checkpoint records how much of the .idx the table reflects"
);
// Everything is durable after the checkpoint, so the map is closed
// first and the snapshot sees the same bytes on every platform.
// (Copying while open fails on Windows, where redb's file lock is
// mandatory: what a crash leaves.)
drop(nm);
// Snapshot the .rdb while the map is still open: what a crash leaves.
let crash_copy = dir.path().join("crash.rdb");
std::fs::copy(&db_path, &crash_copy).unwrap();
drop(nm);
let db = Database::open(&crash_copy).unwrap();
let txn = db.begin_read().unwrap();
let table = txn.open_table(NEEDLE_TABLE).unwrap();
@@ -2249,12 +2215,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let (mut nm, db_path, idx_path) = open_writable_redb(dir.path());
for i in 1..=5u64 {
nm.put(
NeedleId(i),
Offset::from_actual_offset((i * 8) as i64),
Size(1),
)
.unwrap();
nm.put(NeedleId(i), Offset::from_actual_offset((i * 8) as i64), Size(1))
.unwrap();
}
nm.close();
drop(nm);
@@ -2278,12 +2240,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let (mut nm, db_path, idx_path) = open_writable_redb(dir.path());
for i in 1..=5u64 {
nm.put(
NeedleId(i),
Offset::from_actual_offset((i * 8) as i64),
Size(1),
)
.unwrap();
nm.put(NeedleId(i), Offset::from_actual_offset((i * 8) as i64), Size(1))
.unwrap();
}
// Drop without close(): redb makes the table durable on drop, but the
// recorded .idx size stays at its load-time value (0), so the reload
@@ -205,18 +205,6 @@ mod tests {
use std::os::unix::fs::FileExt;
borrowed.read_exact_at(&mut buf, 0).unwrap();
}
#[cfg(windows)]
{
use std::os::windows::fs::FileExt;
let mut filled = 0;
let mut at = 0;
while filled < buf.len() {
let n = borrowed.seek_read(&mut buf[filled..], at).unwrap();
assert!(n != 0, "unexpected EOF in seek_read");
filled += n;
at += n as u64;
}
}
assert_eq!(&buf, b"first");
}
@@ -132,7 +132,9 @@ mod tests {
let mut seen = SeenKeys::new(10_000, FALSE_POSITIVE_RATE);
// Fresh keys may occasionally collide (that is the false-positive
// rate), but only rarely.
let fresh_reported_seen = (0..10_000u64).filter(|&key| seen.test_and_add(key)).count();
let fresh_reported_seen = (0..10_000u64)
.filter(|&key| seen.test_and_add(key))
.count();
assert!(
fresh_reported_seen < 50,
"fresh keys reported seen: {fresh_reported_seen}"
@@ -18,7 +18,6 @@ use std::sync::{Mutex, RwLock};
use super::file_pool::pooled_index_files;
use crate::storage::idx;
use crate::storage::io::read_exact_at;
use crate::storage::needle_map::{CompactNeedleMap, NeedleMapMetric, NeedleValue};
use crate::storage::types::*;
@@ -134,7 +133,9 @@ impl SortedFileNeedleMap {
}
let file = pooled_index_files()
.borrow(&self.db_file_name, false)
.map_err(|e| io::Error::new(e.kind(), format!("open {}: {}", self.db_file_name, e)))?;
.map_err(|e| {
io::Error::new(e.kind(), format!("open {}: {}", self.db_file_name, e))
})?;
match search_sorted_index(&file, self.db_file_size, key)? {
Some((_, offset, size)) => Ok(Some(NeedleValue { offset, size })),
None => Ok(None),
@@ -317,11 +318,9 @@ impl SortedFileNeedleMap {
Ok(())
}
/// Visit all live entries in ascending order by needle ID.
pub fn ascending_visit<F, E>(&self, mut f: F) -> Result<(), E>
pub fn ascending_visit<F>(&self, mut f: F) -> Result<(), String>
where
F: FnMut(NeedleId, &NeedleValue) -> Result<(), E>,
E: From<String>,
F: FnMut(NeedleId, &NeedleValue) -> Result<(), String>,
{
let mut visit_error = None;
self.visit_live_entries(|id, nv| {
@@ -331,7 +330,7 @@ impl SortedFileNeedleMap {
}
Ok(())
})
.map_err(|e| visit_error.take().unwrap_or_else(|| E::from(e.to_string())))
.map_err(|e| visit_error.take().unwrap_or_else(|| e.to_string()))
}
pub fn iter_entries(&self) -> io::Result<Vec<(NeedleId, NeedleValue)>> {
@@ -523,6 +522,32 @@ fn search_sorted_index(
Ok(None)
}
fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
file.read_exact_at(buf, offset)
}
#[cfg(windows)]
{
use std::os::windows::fs::FileExt;
let mut filled = 0;
let mut at = offset;
while filled < buf.len() {
let n = file.seek_read(&mut buf[filled..], at)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF in seek_read",
));
}
filled += n;
at += n as u64;
}
Ok(())
}
}
fn write_at(file: &File, buf: &[u8], offset: u64) -> io::Result<()> {
#[cfg(unix)]
{
@@ -675,11 +700,10 @@ mod tests {
// without a reload — the same contract Go's Get has, where callers
// check size.is_deleted().
assert!(m.get(NeedleId(2)).unwrap().unwrap().size.is_deleted());
assert!(
m.delete(NeedleId(2), Offset::from_actual_offset(16))
.unwrap()
.is_none()
);
assert!(m
.delete(NeedleId(2), Offset::from_actual_offset(16))
.unwrap()
.is_none());
assert!(!m.get(NeedleId(1)).unwrap().unwrap().size.is_deleted());
}
@@ -975,8 +999,7 @@ mod tests {
// The retry is a no-op: no second tombstone, no double counting.
assert_eq!(
m.delete(NeedleId(1), Offset::from_actual_offset(8))
.unwrap(),
m.delete(NeedleId(1), Offset::from_actual_offset(8)).unwrap(),
None
);
assert_eq!(m.deleted_count(), deleted_before + 2);
@@ -1006,8 +1029,7 @@ mod tests {
);
// And a retry must not append a second tombstone for it.
assert_eq!(
m.delete(NeedleId(1), Offset::from_actual_offset(8))
.unwrap(),
m.delete(NeedleId(1), Offset::from_actual_offset(8)).unwrap(),
None
);
}
@@ -1037,7 +1059,7 @@ mod tests {
let mut visited = Vec::new();
m.ascending_visit(|id, _| {
visited.push(id);
Ok::<(), String>(())
Ok(())
})
.unwrap();
assert_eq!(visited, vec![NeedleId(2)]);
+257 -257
View File
@@ -6,19 +6,19 @@
use std::collections::HashSet;
use std::io;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use crate::config::MinFreeSpace;
use crate::pb::master_pb;
use crate::storage::disk_location::DiskLocation;
use crate::storage::erasure_coding::ec_shard::{EcVolumeShard, MAX_SHARD_COUNT, ShardId};
use crate::storage::erasure_coding::ec_shard::{EcVolumeShard, MAX_SHARD_COUNT};
use crate::storage::erasure_coding::ec_volume::EcVolume;
use crate::storage::needle::needle::Needle;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::super_block::ReplicaPlacement;
use crate::storage::types::*;
use crate::storage::volume::{VifVolumeInfo, VolumeError, VolumeSpec};
use crate::storage::volume::{VifVolumeInfo, VolumeError};
/// Top-level storage manager containing all disk locations and their volumes.
pub struct Store {
@@ -300,7 +300,7 @@ impl Store {
collection: &str,
vid: VolumeId,
data_shard_count: u32,
shard_ids: &[ShardId],
shard_ids: &[u32],
) -> Option<usize> {
const TIER_ANY_DISK: u8 = 1;
const TIER_HDD: u8 = 2;
@@ -359,7 +359,7 @@ impl Store {
/// owner (`volume_ec_shards_copy` refuses such a batch instead of
/// guessing). Mirrors `Store.EcShardOwnerDisks` in
/// `weed/storage/store_ec.go`.
pub fn ec_shard_owner_disks(&self, vid: VolumeId, shard_ids: &[ShardId]) -> Vec<usize> {
pub fn ec_shard_owner_disks(&self, vid: VolumeId, shard_ids: &[u32]) -> Vec<usize> {
self.locations
.iter()
.enumerate()
@@ -369,11 +369,16 @@ impl Store {
}
/// Create a new volume, placing it on the location with the most free space.
#[expect(clippy::too_many_arguments)]
pub fn add_volume(
&mut self,
vid: VolumeId,
collection: &str,
replica_placement: Option<ReplicaPlacement>,
ttl: Option<crate::storage::needle::ttl::TTL>,
preallocate: u64,
disk_type: DiskType,
spec: &VolumeSpec<'_>,
version: Version,
) -> Result<(), VolumeError> {
if self.find_volume(vid).is_some() {
return Err(VolumeError::AlreadyExists);
@@ -385,7 +390,15 @@ impl Store {
)))
})?;
self.locations[loc_idx].create_volume(vid, self.needle_map_kind, spec)
self.locations[loc_idx].create_volume(
vid,
collection,
self.needle_map_kind,
replica_placement,
ttl,
preallocate,
version,
)
}
/// Delete a volume from any location. When keep_remote_data is true the
@@ -467,11 +480,12 @@ impl Store {
}
return loc.create_volume(
vid,
collection,
self.needle_map_kind,
&VolumeSpec {
collection,
..Default::default()
},
None,
None,
0,
Version::current(),
);
}
}
@@ -511,8 +525,11 @@ impl Store {
&& collection != "..";
if hint_safe {
for loc in &mut self.locations {
let base =
crate::storage::volume::volume_file_name(&loc.directory, collection, vid);
let base = crate::storage::volume::volume_file_name(
&loc.directory,
collection,
vid,
);
// Confirm a collection-named sidecar exists before using the
// hint. A lone .vif/.idx (e.g. an EC sidecar whose .ecx is on
// a sibling disk) must NOT mount here: create_volume would
@@ -556,11 +573,12 @@ impl Store {
// keep scanning (matches open_volumes / Go mountVolume).
match loc.create_volume(
vid,
collection,
self.needle_map_kind,
&VolumeSpec {
collection,
..Default::default()
},
None,
None,
0,
Version::current(),
) {
Ok(()) => return Ok(()),
Err(e) => {
@@ -591,10 +609,9 @@ impl Store {
&collection,
vid,
);
let has_remote = crate::storage::disk_location::vif_references_remote_file(&format!(
"{}.vif",
base_path
)) || crate::storage::disk_location::vif_references_remote_file(
let has_remote = crate::storage::disk_location::vif_references_remote_file(
&format!("{}.vif", base_path),
) || crate::storage::disk_location::vif_references_remote_file(
&format!("{}.vif", idx_base),
);
if dat_exists || has_remote {
@@ -612,11 +629,12 @@ impl Store {
let loc = &mut self.locations[loc_idx];
match loc.create_volume(
vid,
&collection,
self.needle_map_kind,
&VolumeSpec {
collection: &collection,
..Default::default()
},
None,
None,
0,
Version::current(),
) {
Ok(()) => return Ok(()),
Err(e) => {
@@ -626,12 +644,10 @@ impl Store {
}
}
}
Err(last_err.unwrap_or_else(|| {
VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("volume {} not found on disk", vid),
))
}))
Err(last_err.unwrap_or_else(|| VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("volume {} not found on disk", vid),
))))
}
fn find_volume_file_base(&self, vid: VolumeId) -> Option<(usize, String, String)> {
@@ -764,9 +780,10 @@ impl Store {
// ---- Collection operations ----
/// Delete all volumes in a collection.
pub fn delete_collection(&mut self, collection: &str) -> Result<(), VolumeError> {
pub fn delete_collection(&mut self, collection: &str) -> Result<(), String> {
for loc in &mut self.locations {
loc.delete_collection(collection)?;
loc.delete_collection(collection)
.map_err(|e| format!("delete collection {}: {}", collection, e))?;
}
crate::metrics::delete_collection_metrics(collection);
Ok(())
@@ -881,7 +898,7 @@ impl Store {
&mut self,
vid: VolumeId,
collection: &str,
shard_ids: &[ShardId],
shard_ids: &[u32],
) -> Result<(), VolumeError> {
// Find the location where the EC files live
let loc_idx = self.find_ec_location(vid, collection).ok_or_else(|| {
@@ -902,12 +919,12 @@ impl Store {
&mut self,
vid: VolumeId,
collection: &str,
shard_id: ShardId,
shard_id: u32,
source_disk_type: &str,
) -> Result<(), VolumeError> {
for loc in &mut self.locations {
// Check if the shard file exists on this location
let shard = EcVolumeShard::new(&loc.directory, collection, vid, shard_id);
let shard = EcVolumeShard::new(&loc.directory, collection, vid, shard_id as u8);
if std::path::Path::new(&shard.file_name()).exists() {
loc.mount_ec_shards(vid, collection, &[shard_id], source_disk_type)?;
return Ok(());
@@ -928,7 +945,7 @@ impl Store {
/// the same store (#9252). DiskLocation::unmount_ec_shards
/// already skips shards that aren't mounted, so this is safe to
/// fan out blindly.
pub fn unmount_ec_shards(&mut self, vid: VolumeId, shard_ids: &[ShardId]) {
pub fn unmount_ec_shards(&mut self, vid: VolumeId, shard_ids: &[u32]) {
for loc in &mut self.locations {
if loc.has_ec_volume(vid) {
loc.unmount_ec_shards(vid, shard_ids);
@@ -941,7 +958,7 @@ impl Store {
pub fn unmount_ec_shard(
&mut self,
vid: VolumeId,
shard_id: ShardId,
shard_id: u32,
req_encode_ts_ns: i64,
) -> Result<(), VolumeError> {
// Walk all locations rather than stopping at the first with the
@@ -949,7 +966,7 @@ impl Store {
// multiple disks, with the target shard on any of them.
for disk_id in 0..self.locations.len() {
let ec_vol = self.locations[disk_id].find_ec_volume(vid);
let has_shard = ec_vol.is_some_and(|ec_vol| ec_vol.has_shard(shard_id));
let has_shard = ec_vol.is_some_and(|ec_vol| ec_vol.has_shard(shard_id as u8));
if !has_shard {
continue;
}
@@ -969,7 +986,12 @@ impl Store {
);
continue;
}
tracing::info!(volume_id = vid.0, shard_id, disk_id, "UnmountEcShards");
tracing::info!(
volume_id = vid.0,
shard_id,
disk_id,
"UnmountEcShards"
);
self.locations[disk_id].unmount_ec_shards(vid, &[shard_id]);
}
// Go returns nil if shard not found (no error)
@@ -1068,10 +1090,10 @@ impl Store {
/// disks (each holding a disjoint subset of the shards). Without
/// this, callers using `find_ec_volume(vid)` would only see the
/// first disk and miss shards that live on a sibling.
pub fn find_ec_shard_location(&self, vid: VolumeId, shard_id: ShardId) -> Option<usize> {
pub fn find_ec_shard_location(&self, vid: VolumeId, shard_id: u32) -> Option<usize> {
for (i, loc) in self.locations.iter().enumerate() {
if let Some(ecv) = loc.find_ec_volume(vid)
&& ecv.has_shard(shard_id)
&& ecv.has_shard(shard_id as u8)
{
return Some(i);
}
@@ -1082,10 +1104,10 @@ impl Store {
/// Like [`Self::find_ec_shard_location`] but returns the EcVolume
/// reference directly. Borrows the store immutably for the
/// EcVolume's lifetime.
pub fn find_ec_volume_with_shard(&self, vid: VolumeId, shard_id: ShardId) -> Option<&EcVolume> {
pub fn find_ec_volume_with_shard(&self, vid: VolumeId, shard_id: u32) -> Option<&EcVolume> {
for loc in &self.locations {
if let Some(ecv) = loc.find_ec_volume(vid)
&& ecv.has_shard(shard_id)
&& ecv.has_shard(shard_id as u8)
{
return Some(ecv);
}
@@ -1117,10 +1139,7 @@ impl Store {
found_vol = Some(ecv);
}
for (shard_id, dir) in dirs.iter_mut().enumerate() {
let Ok(sid) = ShardId::try_from(shard_id) else {
continue;
};
if dir.is_none() && ecv.has_shard(sid) {
if dir.is_none() && ecv.has_shard(shard_id as u8) {
*dir = Some(loc.directory.clone());
}
}
@@ -1144,11 +1163,15 @@ impl Store {
for (vid, ec_vol) in loc.ec_volumes() {
if ec_vol.is_time_to_destroy() {
expired_vids.push(*vid);
} else if ec_vol.should_quarantine() {
io_quarantined_vids.push(*vid);
} else {
ec_shards
.extend(ec_vol.to_volume_ec_shard_information_messages(disk_id as u32));
let (_, io_count, quarantined) = ec_vol.get_io_error_state();
if quarantined || io_count >= crate::storage::erasure_coding::ec_volume::IO_ERROR_TOLERANCE
{
io_quarantined_vids.push(*vid);
} else {
ec_shards
.extend(ec_vol.to_volume_ec_shard_information_messages(disk_id as u32));
}
}
}
@@ -1211,12 +1234,12 @@ impl Store {
}
/// Delete EC shard files from disk.
pub fn delete_ec_shards(&mut self, vid: VolumeId, collection: &str, shard_ids: &[ShardId]) {
pub fn delete_ec_shards(&mut self, vid: VolumeId, collection: &str, shard_ids: &[u32]) {
// Delete shard files from disk, tracking which locations actually held one.
let mut deleted_at = vec![false; self.locations.len()];
for (i, loc) in self.locations.iter().enumerate() {
for &shard_id in shard_ids {
let shard = EcVolumeShard::new(&loc.directory, collection, vid, shard_id);
let shard = EcVolumeShard::new(&loc.directory, collection, vid, shard_id as u8);
if std::fs::remove_file(shard.file_name()).is_ok() {
deleted_at[i] = true;
}
@@ -1252,9 +1275,8 @@ impl Store {
collection,
vid,
);
let _ = crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars(
&idx_base,
);
let _ =
crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars(&idx_base);
}
}
}
@@ -1359,6 +1381,18 @@ impl Store {
// ---- Vacuum / Compaction ----
/// Check the garbage level of a volume.
pub fn check_compact_volume(&self, vid: VolumeId) -> Result<f64, String> {
if let Some((_, v)) = self.find_volume(vid) {
Ok(v.garbage_level())
} else {
Err(format!(
"volume id {} is not found during check compact",
vid.0
))
}
}
/// Compact a volume by rewriting only live needles.
pub fn compact_volume<F>(
&mut self,
@@ -1366,53 +1400,68 @@ impl Store {
preallocate: u64,
max_bytes_per_second: i64,
progress_fn: F,
) -> Result<(), VolumeError>
) -> Result<(), String>
where
F: Fn(i64) -> bool,
{
// Required space matches Go's CompactVolume check: the larger of the
// requested preallocation and the estimated volume size.
let (loc_idx, space_needed) = {
let (loc_idx, v) = self
.find_volume(vid)
.ok_or(VolumeError::VolumeNotFound(vid))?;
let estimated = v.dat_file_size().unwrap_or(0) + v.idx_file_size();
(loc_idx, std::cmp::max(preallocate, estimated))
};
let loc_idx = self
.find_volume(vid)
.map(|(i, _)| i)
.ok_or_else(|| format!("volume id {} is not found during compact", vid.0))?;
let dir = self.locations[loc_idx].directory.clone();
let (_, free) = crate::storage::disk_location::get_disk_stats(&dir);
// Compute required space: use the larger of preallocate or estimated volume size
// matching Go's CompactVolume space check
let space_needed = {
let (_, v) = self.find_volume(vid).unwrap();
let estimated = v.dat_file_size().unwrap_or(0) + v.idx_file_size();
std::cmp::max(preallocate, estimated)
};
if free < space_needed {
return Err(VolumeError::InsufficientSpace {
vid,
required: space_needed,
free,
});
return Err(format!(
"not enough free space to compact volume {}. Required: {}, Free: {}",
vid.0, space_needed, free
));
}
let (_, v) = self
.find_volume_mut(vid)
.ok_or(VolumeError::VolumeNotFound(vid))?;
v.compact_by_index(preallocate, max_bytes_per_second, progress_fn)
if let Some((_, v)) = self.find_volume_mut(vid) {
v.compact_by_index(preallocate, max_bytes_per_second, progress_fn)
.map_err(|e| format!("compact volume {}: {}", vid.0, e))
} else {
Err(format!("volume id {} is not found during compact", vid.0))
}
}
/// Commit a completed compaction: swap files and reload.
pub fn commit_compact_volume(&mut self, vid: VolumeId) -> Result<(bool, u64), VolumeError> {
let (_, v) = self
.find_volume_mut(vid)
.ok_or(VolumeError::VolumeNotFound(vid))?;
let is_read_only = v.is_read_only();
v.commit_compact()?;
let volume_size = v.dat_file_size().unwrap_or(0);
Ok((is_read_only, volume_size))
pub fn commit_compact_volume(&mut self, vid: VolumeId) -> Result<(bool, u64), String> {
if let Some((_, v)) = self.find_volume_mut(vid) {
let is_read_only = v.is_read_only();
v.commit_compact()
.map_err(|e| format!("commit compact volume {}: {}", vid.0, e))?;
let volume_size = v.dat_file_size().unwrap_or(0);
Ok((is_read_only, volume_size))
} else {
Err(format!(
"volume id {} is not found during commit compact",
vid.0
))
}
}
/// Clean up leftover compaction files.
pub fn cleanup_compact_volume(&mut self, vid: VolumeId) -> Result<(), VolumeError> {
let (_, v) = self
.find_volume_mut(vid)
.ok_or(VolumeError::VolumeNotFound(vid))?;
v.cleanup_compact()
pub fn cleanup_compact_volume(&mut self, vid: VolumeId) -> Result<(), String> {
if let Some((_, v)) = self.find_volume_mut(vid) {
v.cleanup_compact()
.map_err(|e| format!("cleanup volume {}: {}", vid.0, e))
} else {
Err(format!(
"volume id {} is not found during cleaning up",
vid.0
))
}
}
/// Close all locations and their volumes.
@@ -1525,13 +1574,13 @@ fn ec_free_shard_count(loc: &DiskLocation, data_shard_count: u32) -> i64 {
/// the in-memory registration the read path and heartbeats use.
///
/// Mirrors `ownedEcShardCount` in `weed/storage/store_ec.go`.
fn owned_ec_shard_count(loc: &DiskLocation, vid: VolumeId, shard_ids: &[ShardId]) -> usize {
fn owned_ec_shard_count(loc: &DiskLocation, vid: VolumeId, shard_ids: &[u32]) -> usize {
let Some(ecv) = loc.find_ec_volume(vid) else {
return 0;
};
shard_ids
.iter()
.filter(|&&shard_id| ecv.has_shard(shard_id))
.filter(|&&shard_id| ecv.has_shard(shard_id as u8))
.count()
}
@@ -1590,7 +1639,15 @@ mod tests {
let mut store = make_test_store(&[dir]);
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.add_volume(
VolumeId(1),
"",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
assert!(store.has_volume(VolumeId(1)));
assert!(!store.has_volume(VolumeId(2)));
@@ -1629,7 +1686,10 @@ mod tests {
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
let escaped = format!("{}/../evil_5.dat", dir);
let escaped = format!(
"{}/../evil_5.dat",
dir
);
let err = store
.mount_volume_by_id(VolumeId(5), Some("../evil"))
.unwrap_err();
@@ -1649,11 +1709,12 @@ mod tests {
store
.add_volume(
VolumeId(7),
"coll",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "coll",
..Default::default()
},
Version::current(),
)
.unwrap();
// Write a needle so the volume has real data, then unmount it so the
@@ -1670,7 +1731,9 @@ mod tests {
.unwrap();
assert!(store.unmount_volume(VolumeId(7)));
store.mount_volume_by_id(VolumeId(7), Some("coll")).unwrap();
store
.mount_volume_by_id(VolumeId(7), Some("coll"))
.unwrap();
assert!(store.find_volume(VolumeId(7)).is_some());
let mut got = Needle {
@@ -1693,11 +1756,12 @@ mod tests {
store
.add_volume(
VolumeId(9),
"foo..bar",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "foo..bar",
..Default::default()
},
Version::current(),
)
.unwrap();
let mut n = Needle {
@@ -1707,9 +1771,7 @@ mod tests {
data_size: 4,
..Needle::default()
};
store
.write_volume_needle(VolumeId(9), &mut n, false)
.unwrap();
store.write_volume_needle(VolumeId(9), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(9)));
// The hint is accepted and mounts the volume.
@@ -1738,11 +1800,12 @@ mod tests {
store
.add_volume(
VolumeId(11),
"coll",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "coll",
..Default::default()
},
Version::current(),
)
.unwrap();
let mut n = Needle {
@@ -1752,9 +1815,7 @@ mod tests {
data_size: 7,
..Needle::default()
};
store
.write_volume_needle(VolumeId(11), &mut n, false)
.unwrap();
store.write_volume_needle(VolumeId(11), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(11)));
// Simulate an interrupted copy: drop a .note marker.
@@ -1795,11 +1856,12 @@ mod tests {
store
.add_volume(
VolumeId(13),
"coll",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "coll",
..Default::default()
},
Version::current(),
)
.unwrap();
let mut n = Needle {
@@ -1809,9 +1871,7 @@ mod tests {
data_size: 4,
..Needle::default()
};
store
.write_volume_needle(VolumeId(13), &mut n, false)
.unwrap();
store.write_volume_needle(VolumeId(13), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(13)));
// No hint: the fallback scan finds the sidecar on disk 0 first (skip,
@@ -1844,17 +1904,16 @@ mod tests {
let mut store = make_test_store(&[dir0, dir1]);
// Force add_volume onto disk 1 by marking disk 0 as low on space.
store.locations[0]
.is_disk_space_low
.store(true, Ordering::Relaxed);
store.locations[0].is_disk_space_low.store(true, Ordering::Relaxed);
store
.add_volume(
VolumeId(15),
"coll",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "coll",
..Default::default()
},
Version::current(),
)
.unwrap();
let mut n = Needle {
@@ -1864,14 +1923,10 @@ mod tests {
data_size: 5,
..Needle::default()
};
store
.write_volume_needle(VolumeId(15), &mut n, false)
.unwrap();
store.write_volume_needle(VolumeId(15), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(15)));
// Clear the low-space flag so mount_volume_by_id considers disk 0.
store.locations[0]
.is_disk_space_low
.store(false, Ordering::Relaxed);
store.locations[0].is_disk_space_low.store(false, Ordering::Relaxed);
// disk 0: a .dat that exists but is unreadable (chmod 000). The guard
// sees dat_exists=true (metadata succeeds, not a dir), but
@@ -1910,7 +1965,15 @@ mod tests {
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.add_volume(
VolumeId(1),
"",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
// Write
@@ -1976,7 +2039,15 @@ mod tests {
)
.unwrap();
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.add_volume(
VolumeId(1),
"",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(1),
@@ -2039,10 +2110,26 @@ mod tests {
// Add volumes — should go to location with fewest volumes
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.add_volume(
VolumeId(1),
"",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
store
.add_volume(VolumeId(2), DiskType::HardDrive, &VolumeSpec::default())
.add_volume(
VolumeId(2),
"",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
assert_eq!(store.total_volume_count(), 2);
@@ -2060,31 +2147,34 @@ mod tests {
store
.add_volume(
VolumeId(1),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
store
.add_volume(
VolumeId(2),
"pics",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "pics",
..Default::default()
},
Version::current(),
)
.unwrap();
store
.add_volume(
VolumeId(3),
"docs",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "docs",
..Default::default()
},
Version::current(),
)
.unwrap();
assert_eq!(store.total_volume_count(), 3);
@@ -2120,21 +2210,23 @@ mod tests {
store
.add_volume(
VolumeId(61),
"preallocate_case",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "preallocate_case",
..Default::default()
},
Version::current(),
)
.unwrap();
store
.add_volume(
VolumeId(62),
"preallocate_case",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "preallocate_case",
..Default::default()
},
Version::current(),
)
.unwrap();
for vid in [VolumeId(61), VolumeId(62)] {
@@ -2235,11 +2327,12 @@ mod tests {
store
.add_volume(
VolumeId(71),
"find_free_location_case",
None,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
collection: "find_free_location_case",
..Default::default()
},
Version::current(),
)
.unwrap();
@@ -2291,88 +2384,6 @@ mod tests {
assert!(matches!(err, Err(VolumeError::NotFound)));
}
#[test]
fn test_compaction_of_missing_volume_is_volume_not_found() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
let missing = VolumeId(4242);
let compact = store.compact_volume(missing, 0, 0, |_| true);
assert!(
matches!(compact, Err(VolumeError::VolumeNotFound(v)) if v == missing),
"{compact:?}"
);
let commit = store.commit_compact_volume(missing);
assert!(
matches!(commit, Err(VolumeError::VolumeNotFound(v)) if v == missing),
"{commit:?}"
);
let cleanup = store.cleanup_compact_volume(missing);
assert!(
matches!(cleanup, Err(VolumeError::VolumeNotFound(v)) if v == missing),
"{cleanup:?}"
);
assert_eq!(
VolumeError::VolumeNotFound(missing).to_string(),
"volume id 4242 is not found"
);
}
#[test]
fn test_compact_then_commit_reclaims_a_deleted_needle() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
let vid = VolumeId(1);
store
.add_volume(vid, DiskType::HardDrive, &VolumeSpec::default())
.unwrap();
for i in 1..=3u64 {
let payload = format!("data-{i}").into_bytes();
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data_size: payload.len() as u32,
data: payload,
..Needle::default()
};
store.write_volume_needle(vid, &mut n, true).unwrap();
}
let mut del = Needle {
id: NeedleId(2),
cookie: Cookie(2),
..Needle::default()
};
store.delete_volume_needle(vid, &mut del).unwrap();
let size_before = store.find_volume(vid).unwrap().1.dat_file_size().unwrap();
// preallocate 0, unthrottled, progress fn that never cancels.
store.compact_volume(vid, 0, 0, |_| true).unwrap();
let (is_read_only, volume_size) = store.commit_compact_volume(vid).unwrap();
assert!(!is_read_only);
assert!(
volume_size < size_before,
"compaction must drop the deleted needle: {volume_size} vs {size_before}"
);
let (_, v) = store
.find_volume(vid)
.expect("the volume stays mounted across a commit");
assert_eq!(v.file_count(), 2);
assert_eq!(v.deleted_count(), 0);
assert_eq!(v.dat_file_size().unwrap(), volume_size);
}
/// Build a Store with N HDD disk locations under a single TempDir.
/// Returns the store and the TempDir guard so callers keep the dirs
/// alive for the test's lifetime.
@@ -2546,10 +2557,7 @@ mod tests {
// Mount an EC shard on disk 1 so has_ec_volume returns true.
std::fs::write(
format!(
"{}/{}_{}.ec00",
store.locations[1].directory, collection, vid.0
),
format!("{}/{}_{}.ec00", store.locations[1].directory, collection, vid.0),
b"shard data",
)
.unwrap();
@@ -2562,12 +2570,7 @@ mod tests {
std::fs::write(format!("{}.ecx", base), vec![0u8; 20]).unwrap();
let got = store.find_ec_shard_target_location(collection, vid, 10, &[]);
assert_eq!(
got,
Some(1),
"expected the mounted disk to win; got {:?}",
got
);
assert_eq!(got, Some(1), "expected the mounted disk to win; got {:?}", got);
}
/// Cold-volume case: no mount, no `.ecx` anywhere on this server.
@@ -2621,10 +2624,7 @@ mod tests {
// free shard slots remaining; the old formula would have
// rounded that to 0.
std::fs::write(
format!(
"{}/{}_{}.ec00",
store.locations[1].directory, collection, vid.0
),
format!("{}/{}_{}.ec00", store.locations[1].directory, collection, vid.0),
b"shard data",
)
.unwrap();
@@ -2713,7 +2713,7 @@ mod tests {
// Fill disk 0 past its shard-slot budget so ec_free_shard_count is 0.
let filler = VolumeId(10000);
let filler_base = volume_file_name(&store.locations[0].directory, collection, filler);
let filler_shards: Vec<ShardId> = (0..10).collect();
let filler_shards: Vec<u32> = (0..10).collect();
for shard_id in &filler_shards {
std::fs::write(format!("{}.ec{:02}", filler_base, shard_id), b"x").unwrap();
}
@@ -8,7 +8,7 @@ use std::path::Path;
use tracing::{info, warn};
use crate::storage::disk_location::{DiskLocation, parse_collection_volume_id_pub};
use crate::storage::disk_location::{parse_collection_volume_id_pub, DiskLocation};
use crate::storage::store::Store;
use crate::storage::types::VolumeId;
@@ -286,7 +286,9 @@ fn collect_shard_disk_volumes(loc: &DiskLocation) -> HashMap<EcKey, Vec<String>>
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
out.entry(EcKey { collection, vid }).or_default().push(name);
out.entry(EcKey { collection, vid })
.or_default()
.push(name);
}
out
}
@@ -21,7 +21,7 @@ use std::fs;
use tracing::{error, info, warn};
use crate::storage::disk_location::{is_ec_shard_extension, parse_collection_volume_id_pub};
use crate::storage::erasure_coding::ec_shard::{DATA_SHARDS_COUNT, ShardId};
use crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
use crate::storage::store::Store;
use crate::storage::types::VolumeId;
@@ -83,7 +83,7 @@ struct EcxOwnerInfo {
/// One unit of reconcile work: the disk holding orphan shards, the volume
/// they belong to, the shard files, the `.ecx` owner, and whether the
/// mirror already installed sidecars locally (`use_local_idx`).
type OrphanShardLoad = (usize, EcKey, Vec<(String, ShardId)>, EcxOwnerInfo, bool);
type OrphanShardLoad = (usize, EcKey, Vec<(String, u32)>, EcxOwnerInfo, bool);
impl Store {
/// Run cross-disk orphan-shard reconciliation. Should be called
@@ -122,7 +122,9 @@ impl Store {
let use_local_idx = std::path::Path::new(&local_ecx).exists()
|| std::path::Path::new(&local_ecx_in_data).exists();
if !use_local_idx && owner.location == loc_idx && owner.idx_dir == loc.idx_directory
if !use_local_idx
&& owner.location == loc_idx
&& owner.idx_dir == loc.idx_directory
{
// Same-disk no-op: load_all_ec_shards already
// tried and logged the failure.
@@ -135,7 +137,7 @@ impl Store {
for (loc_idx, key, shards, owner, use_local_idx) in to_load {
let shard_names: Vec<&str> = shards.iter().map(|(n, _)| n.as_str()).collect();
let loc_dir = self.locations[loc_idx].directory.clone();
let shard_ids: Vec<ShardId> = shards.iter().map(|(_, sid)| *sid).collect();
let shard_ids: Vec<u32> = shards.iter().map(|(_, sid)| *sid).collect();
if use_local_idx {
info!(
@@ -477,13 +479,13 @@ impl Store {
/// Unlike `reconcile_ec_shards_across_disks` it needs no sibling disk, so a
/// single-disk store recovers once its index has been fetched from a peer.
fn load_orphan_ec_shards_with_local_index(&mut self) {
let mut work: Vec<(usize, EcKey, Vec<ShardId>)> = Vec::new();
let mut work: Vec<(usize, EcKey, Vec<u32>)> = Vec::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
for (key, shards) in collect_orphan_ec_shards(loc, loc_idx) {
if !loc.has_ecx_file_on_disk(&key.collection, key.vid) {
continue;
}
let ids: Vec<ShardId> = shards.iter().map(|(_, sid)| *sid).collect();
let ids: Vec<u32> = shards.iter().map(|(_, sid)| *sid).collect();
work.push((loc_idx, key, ids));
}
}
@@ -510,8 +512,8 @@ impl Store {
fn collect_orphan_ec_shards(
loc: &crate::storage::disk_location::DiskLocation,
_loc_idx: usize,
) -> HashMap<EcKey, Vec<(String, ShardId)>> {
let mut orphans: HashMap<EcKey, Vec<(String, ShardId)>> = HashMap::new();
) -> HashMap<EcKey, Vec<(String, u32)>> {
let mut orphans: HashMap<EcKey, Vec<(String, u32)>> = HashMap::new();
let Ok(read) = fs::read_dir(&loc.directory) else {
return orphans;
};
@@ -539,7 +541,7 @@ fn collect_orphan_ec_shards(
};
// Skip shards that are already registered to an EcVolume.
if let Some(ecv) = loc.find_ec_volume(vid)
&& ecv.has_shard(shard_id)
&& ecv.has_shard(shard_id as u8)
{
continue;
}
@@ -591,13 +593,7 @@ mod tests {
std::fs::write(&p, b"shard data nonempty").unwrap();
}
fn write_index_files(
idx_dir: &str,
collection: &str,
vid: u32,
data_shards: u32,
parity_shards: u32,
) {
fn write_index_files(idx_dir: &str, collection: &str, vid: u32, data_shards: u32, parity_shards: u32) {
// Minimal sealed .ecx (the loader only opens the file; it
// doesn't parse it during placement).
std::fs::write(
@@ -951,24 +947,15 @@ mod tests {
// dir1 owns the .ecx and so already has shard 1 mounted via
// its own load_all_ec_shards.
let ev1 = store.locations[1].find_ec_volume(VolumeId(vid));
assert!(
ev1.is_some(),
"baseline broken: dir1 should have mounted shard 1"
);
assert!(ev1.is_some(), "baseline broken: dir1 should have mounted shard 1");
// dir0's shards must be reconciled across to its own
// ec_volumes map, pointing at dir1's idx dir.
let ev0 = store.locations[0]
.find_ec_volume(VolumeId(vid))
.expect("dir0 should now have an EcVolume after reconcile");
assert!(
ev0.has_shard(0),
"shard 0 missing from dir0 after reconcile"
);
assert!(
ev0.has_shard(12),
"shard 12 missing from dir0 after reconcile"
);
assert!(ev0.has_shard(0), "shard 0 missing from dir0 after reconcile");
assert!(ev0.has_shard(12), "shard 12 missing from dir0 after reconcile");
}
/// PR 9244 review case: idx_directory is configured but the
@@ -1077,13 +1064,7 @@ mod tests {
assert!(store.locations[0].find_ec_volume(VolumeId(vid)).is_none());
// Shard files must still exist on disk for operator recovery.
for sid in [0u8, 12u8] {
let p = format!(
"{}/{}_{}.ec{:02}",
dir0.to_str().unwrap(),
collection,
vid,
sid
);
let p = format!("{}/{}_{}.ec{:02}", dir0.to_str().unwrap(), collection, vid, sid);
assert!(
std::path::Path::new(&p).exists(),
"orphan shard {} was destroyed",
@@ -1148,12 +1129,10 @@ mod tests {
assert!(ev1.has_shard(6), "dir1 shard missing");
// Nothing left to recover.
assert!(
store
.collect_ec_volumes_missing_index()
.iter()
.all(|m| m.vid != VolumeId(vid))
);
assert!(store
.collect_ec_volumes_missing_index()
.iter()
.all(|m| m.vid != VolumeId(vid)));
}
#[test]
@@ -1185,12 +1164,10 @@ mod tests {
.unwrap();
}
assert!(
store
.collect_ec_volumes_missing_index()
.iter()
.all(|m| m.vid != VolumeId(vid))
);
assert!(store
.collect_ec_volumes_missing_index()
.iter()
.all(|m| m.vid != VolumeId(vid)));
}
/// Helper: build a 2-disk store where reconcile produces the
@@ -1268,11 +1245,7 @@ mod tests {
let vid = VolumeId(7010);
let all = store.find_all_ec_volumes(vid);
assert_eq!(
all.len(),
2,
"expected one EcVolume per disk holding the vid"
);
assert_eq!(all.len(), 2, "expected one EcVolume per disk holding the vid");
// Disk 0 carries shards 0 and 12; disk 1 carries shard 1.
assert!(all[0].has_shard(0));
@@ -1293,7 +1266,7 @@ mod tests {
#[test]
fn test_scrub_plans_reach_every_disk_through_the_store() {
use crate::storage::erasure_coding::ec_volume::{
EcChecksumScrubPlan, EcLocalScrubPlan, merge_ec_runtimes,
merge_ec_runtimes, EcChecksumScrubPlan, EcLocalScrubPlan,
};
let (store, _tmp) = build_split_disk_store(7030);
@@ -1308,15 +1281,8 @@ mod tests {
let merged = merge_ec_runtimes(&runtimes).expect("two runtimes merge");
assert!(merged.slots[0].is_some(), "disk 0's shard 0 unreachable");
assert!(merged.slots[12].is_some(), "disk 0's shard 12 unreachable");
assert!(
merged.slots[1].is_some(),
"disk 1's shard 1 unreachable — the bug"
);
assert!(
merged.skipped.is_empty(),
"same generation: {:?}",
merged.skipped
);
assert!(merged.slots[1].is_some(), "disk 1's shard 1 unreachable — the bug");
assert!(merged.skipped.is_empty(), "same generation: {:?}", merged.skipped);
// Shard 1 is owned by the sibling runtime, not the anchor.
let (owner, _) = merged.slots[1].unwrap();
@@ -1423,22 +1389,17 @@ mod tests {
let (_ev, dirs) = store.collect_ec_shard_dirs(vid, max_shards).unwrap();
// Shards 0 and 12 → disk 0's directory.
assert_eq!(
dirs[0].as_deref(),
Some(store.locations[0].directory.as_str())
);
assert_eq!(
dirs[12].as_deref(),
Some(store.locations[0].directory.as_str())
);
assert_eq!(dirs[0].as_deref(), Some(store.locations[0].directory.as_str()));
assert_eq!(dirs[12].as_deref(), Some(store.locations[0].directory.as_str()));
// Shard 1 → disk 1's directory.
assert_eq!(
dirs[1].as_deref(),
Some(store.locations[1].directory.as_str())
);
assert_eq!(dirs[1].as_deref(), Some(store.locations[1].directory.as_str()));
// Unmounted shards → None.
for sid in [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13] {
assert_eq!(dirs[sid], None, "shard {} unexpectedly reported a dir", sid,);
assert_eq!(
dirs[sid], None,
"shard {} unexpectedly reported a dir",
sid,
);
}
}
@@ -1747,7 +1708,11 @@ mod tests {
vec![0u8; 20],
)
.unwrap();
std::fs::write(ec_dir.join(format!("{}_{}.ecj", collection, vid)), b"").unwrap();
std::fs::write(
ec_dir.join(format!("{}_{}.ecj", collection, vid)),
b"",
)
.unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
store
-29
View File
@@ -221,35 +221,6 @@ mod tests {
use super::*;
use crate::storage::types::*;
/// Multi-byte input must be an error, not a panic: `to_digit` on the
/// leading characters rejects it before `chars[2]` is ever indexed.
#[test]
fn replica_placement_rejects_non_ascii_instead_of_panicking() {
for s in ["é", "0é", "é0", "🦀", "ééé"] {
assert!(
ReplicaPlacement::from_string(s).is_err(),
"non-ASCII replication {:?} must error",
s
);
}
}
/// The ASCII guard must not change any accepted input, including the
/// zero-padding shorthands.
#[test]
fn replica_placement_still_accepts_ascii_shorthands() {
assert_eq!(
ReplicaPlacement::from_string("1").unwrap(),
ReplicaPlacement::from_string("001").unwrap()
);
assert_eq!(
ReplicaPlacement::from_string("01").unwrap(),
ReplicaPlacement::from_string("001").unwrap()
);
let rp = ReplicaPlacement::from_string("010").unwrap();
assert_eq!(rp.diff_rack_count, 1);
}
#[test]
fn test_super_block_round_trip() {
let sb = SuperBlock {
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@ use crate::storage::needle::Needle;
use crate::storage::super_block::SuperBlock;
use crate::storage::types::*;
use crate::storage::volume::{
Volume, VolumeError, VolumeFileVisitor, fsync_dir, needle_disk_end, scan_volume_file,
fsync_dir, needle_disk_end, scan_volume_file, Volume, VolumeError, VolumeFileVisitor,
};
/// Writes one .idx row per .dat record, in .dat append order, which is the
@@ -105,11 +105,11 @@ impl Volume {
#[cfg(test)]
mod tests {
use crate::storage::needle::Needle;
use crate::storage::needle::crc::CRC;
use crate::storage::needle::Needle;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::types::*;
use crate::storage::volume::{Volume, VolumeSpec};
use crate::storage::volume::Volume;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
@@ -145,9 +145,13 @@ mod tests {
let mut v = Volume::new(
data,
old_idx,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
for id in 1..=3 {
@@ -163,9 +167,13 @@ mod tests {
let reopened = Volume::new(
data,
new_idx,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
@@ -199,9 +207,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
v.write_needle(&mut needle(1), true, false).unwrap();
@@ -221,9 +233,13 @@ mod tests {
let reopened = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
drop(reopened);
@@ -245,9 +261,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
v.write_needle(&mut needle(1), true, false).unwrap();
@@ -270,9 +290,13 @@ mod tests {
let reopened = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
drop(reopened);
@@ -294,9 +318,13 @@ mod tests {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
v.write_needle(&mut needle(1), true, false).unwrap();
@@ -328,9 +356,13 @@ mod tests {
let reopened = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
drop(reopened);
+16 -61
View File
@@ -9,10 +9,10 @@ use std::path::Path;
use tracing::info;
use crate::storage::idx;
use crate::storage::needle::Needle;
use crate::storage::needle::needle::needle_body_length;
use crate::storage::needle::Needle;
use crate::storage::types::*;
use crate::storage::volume::{Volume, VolumeError, fsync_dir};
use crate::storage::volume::{fsync_dir, Volume, VolumeError};
/// Needles found in the head of .dat, keyed by id, plus the ids in .dat order.
type DatHeadNeedles = (HashMap<NeedleId, (Offset, Size)>, Vec<NeedleId>);
@@ -122,19 +122,7 @@ impl Volume {
} else {
found.remove(&id);
}
let record_size = NEEDLE_HEADER_SIZE as i64 + needle_body_length(size, version);
// A corrupt header can make the record length zero or negative;
// the scan cannot advance past it.
if record_size <= 0 {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"corrupt needle header at offset {offset}: size {}, record length {record_size}",
size.0
),
)));
}
offset += record_size;
offset += NEEDLE_HEADER_SIZE as i64 + needle_body_length(size, version);
}
Ok((found, order))
@@ -227,19 +215,20 @@ mod tests {
use super::*;
use crate::storage::needle::crc::CRC;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::volume::VolumeSpec;
use std::io::{Seek, SeekFrom};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::{FileExt, PermissionsExt};
use tempfile::TempDir;
fn open_volume(dir: &str) -> Volume {
Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap()
}
@@ -267,7 +256,7 @@ mod tests {
/// writes (key, offset 0, tombstone) rows over the front of .idx instead of
/// appending them.
fn clobber_idx_head(idx_path: &str, keys: &[u64]) {
let mut file = OpenOptions::new().write(true).open(idx_path).unwrap();
let file = OpenOptions::new().write(true).open(idx_path).unwrap();
for (i, key) in keys.iter().enumerate() {
let mut row = Vec::new();
idx::write_index_entry(
@@ -277,13 +266,8 @@ mod tests {
TOMBSTONE_FILE_SIZE,
)
.unwrap();
// Positional write without Unix-only `FileExt::write_at`, so this
// helper (and the tests using it) also builds on Windows.
// Single-threaded test helper: no concurrent reader can move the
// offset between seek and write.
file.seek(SeekFrom::Start((i * NEEDLE_MAP_ENTRY_SIZE) as u64))
file.write_at(&row, (i * NEEDLE_MAP_ENTRY_SIZE) as u64)
.unwrap();
file.write_all(&row).unwrap();
}
}
@@ -321,7 +305,6 @@ mod tests {
let size_before = idx_size(&idx_path);
// The rewrite replaces .idx wholesale, so it must not widen the mode.
#[cfg(unix)]
fs::set_permissions(&idx_path, fs::Permissions::from_mode(0o600)).unwrap();
// Deletes against needles 9..12 land on the front of .idx and take the
@@ -341,14 +324,11 @@ mod tests {
let want = size_before + 4 * NEEDLE_MAP_ENTRY_SIZE as u64;
assert_eq!(idx_size(&idx_path), want, "idx size after recovery");
#[cfg(unix)]
{
assert_eq!(
fs::metadata(&idx_path).unwrap().permissions().mode() & 0o777,
0o600,
"idx mode after recovery"
);
}
assert_eq!(
fs::metadata(&idx_path).unwrap().permissions().mode() & 0o777,
0o600,
"idx mode after recovery"
);
// The recovered rows go back in front, so .idx is in .dat append order
// again: the fingerprint is gone and the last row is still the .dat tail.
@@ -438,29 +418,4 @@ mod tests {
"needle 1 should have been recovered"
);
}
#[test]
fn test_scan_dat_head_fails_at_a_header_it_cannot_advance_past() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
write_test_volume(dir, 2);
let mut dat = OpenOptions::new()
.append(true)
.open(format!("{}/1.dat", dir))
.unwrap();
let mut corrupt = [0u8; NEEDLE_HEADER_SIZE];
NeedleId(99).to_bytes(&mut corrupt[4..12]);
Size(-100).to_bytes(&mut corrupt[12..16]);
dat.write_all(&corrupt).unwrap();
drop(dat);
let v = open_volume(dir);
let first = v.super_block.block_size() as i64;
let err = v.scan_dat_head(v.version(), first, 10).unwrap_err();
assert!(
matches!(&err, VolumeError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
"expected a corrupt-data error, got {err:?}"
);
}
}
+16 -65
View File
@@ -20,71 +20,26 @@ use std::collections::{HashMap, HashSet};
fn ungated_handlers() -> HashMap<&'static str, &'static str> {
[
// Cluster-internal: issued volume server -> volume server.
(
"copy_file",
"replica sync and EC task pull whole files from a peer",
),
(
"read_needle_blob",
"replica sync, vacuum and EC rebuild read needles from a peer",
),
(
"read_needle_meta",
"replica sync compares needle metadata across peers",
),
("copy_file", "replica sync and EC task pull whole files from a peer"),
("read_needle_blob", "replica sync, vacuum and EC rebuild read needles from a peer"),
("read_needle_meta", "replica sync compares needle metadata across peers"),
("write_needle_blob", "replica sync repairs a peer's needle"),
(
"receive_file",
"EC shard distribution pushes shards to a peer",
),
(
"read_volume_file_status",
"the copy path queries the source volume server",
),
(
"volume_ec_shard_read",
"a volume server reads EC shards held by a peer",
),
(
"volume_ec_blob_delete",
"EC delete is fanned out to the shard holders",
),
(
"volume_ec_shards_info",
"EC verification polls shard holders",
),
(
"volume_ec_shards_mount",
"EC shard distribution mounts on the receiving peer",
),
(
"volume_incremental_copy",
"volume backup pulls increments from a peer",
),
(
"volume_sync_status",
"sync compares volume state across peers",
),
(
"volume_tail_sender",
"the tail source streams to the receiving peer",
),
(
"volume_status",
"replica sync and the master's vacuum loop poll volume status",
),
("receive_file", "EC shard distribution pushes shards to a peer"),
("read_volume_file_status", "the copy path queries the source volume server"),
("volume_ec_shard_read", "a volume server reads EC shards held by a peer"),
("volume_ec_blob_delete", "EC delete is fanned out to the shard holders"),
("volume_ec_shards_info", "EC verification polls shard holders"),
("volume_ec_shards_mount", "EC shard distribution mounts on the receiving peer"),
("volume_incremental_copy", "volume backup pulls increments from a peer"),
("volume_sync_status", "sync compares volume state across peers"),
("volume_tail_sender", "the tail source streams to the receiving peer"),
("volume_status", "replica sync and the master's vacuum loop poll volume status"),
// Read-only or liveness: no state change.
("ping", "liveness probe"),
("get_state", "read-only volume server state"),
("query", "read-only data query"),
(
"vacuum_volume_check",
"read-only garbage ratio; the vacuum steps that act on it are gated",
),
(
"volume_server_status",
"read-only status, the gRPC counterpart of the /status page",
),
("vacuum_volume_check", "read-only garbage ratio; the vacuum steps that act on it are gated"),
("volume_server_status", "read-only status, the gRPC counterpart of the /status page"),
]
.into_iter()
.collect()
@@ -174,9 +129,5 @@ fn volume_server_admin_auth_coverage() {
}
}
assert!(
problems.is_empty(),
"admin-auth coverage gaps:\n{}",
problems.join("\n")
);
assert!(problems.is_empty(), "admin-auth coverage gaps:\n{}", problems.join("\n"));
}
+18 -227
View File
@@ -12,13 +12,12 @@ use tower::ServiceExt; // for `oneshot`
use seaweed_volume::security::{Guard, SigningKey};
use seaweed_volume::server::volume_server::{
VolumeServerState, build_admin_router, build_admin_router_with_ui, build_metrics_router,
build_public_router,
build_admin_router, build_admin_router_with_ui, build_metrics_router, build_public_router,
VolumeServerState,
};
use seaweed_volume::storage::needle_map::NeedleMapKind;
use seaweed_volume::storage::store::Store;
use seaweed_volume::storage::types::{DiskType, VolumeId};
use seaweed_volume::storage::volume::VolumeSpec;
use seaweed_volume::storage::types::{DiskType, Version, VolumeId};
use tempfile::TempDir;
@@ -74,11 +73,12 @@ fn build_test_state(
store
.add_volume(
VolumeId(1),
"",
replica_placement,
None,
0,
DiskType::HardDrive,
&VolumeSpec {
replica_placement,
..Default::default()
},
Version::current(),
)
.expect("failed to create volume");
@@ -112,6 +112,9 @@ fn build_test_state(
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
s3_tier_registry: std::sync::RwLock::new(
seaweed_volume::remote_storage::s3_tier::S3TierRegistry::new(),
),
read_mode: seaweed_volume::config::ReadMode::Local,
allow_untrusted_remote_endpoints: false,
master_url,
@@ -954,10 +957,9 @@ async fn replicate_write_does_not_re_replicate() {
#[tokio::test]
async fn chunk_manifest_expands_chunk_stored_on_ec_volume() {
use seaweed_volume::storage::erasure_coding::ec_encoder::write_ec_files;
use seaweed_volume::storage::erasure_coding::ec_shard::ShardId;
use seaweed_volume::storage::needle::needle::{FileId, Needle};
use seaweed_volume::storage::types::{Cookie, NeedleId};
use seaweed_volume::storage::volume::{Volume, VolumeSpec};
use seaweed_volume::storage::volume::Volume;
let (state, tmp) = test_state();
let dir = tmp.path().to_str().unwrap();
@@ -974,9 +976,13 @@ async fn chunk_manifest_expands_chunk_stored_on_ec_volume() {
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(2),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
None,
None,
0,
Version::current(),
)
.unwrap();
let mut n = Needle {
@@ -996,7 +1002,7 @@ async fn chunk_manifest_expands_chunk_stored_on_ec_volume() {
// after ec.encode retired the regular volume.
{
let mut store = state.store.write().unwrap();
let shard_ids: Vec<ShardId> = (0..14).collect();
let shard_ids: Vec<u32> = (0..14).collect();
store.mount_ec_shards(VolumeId(2), "", &shard_ids).unwrap();
}
@@ -1040,218 +1046,3 @@ async fn chunk_manifest_expands_chunk_stored_on_ec_volume() {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_bytes(response).await, chunk_data);
}
// ============================================================================
// HTTP DELETE on an EC volume whose shards are not all mounted locally
//
// The delete handler used to validate the cookie with the local-only
// `EcVolume::read_ec_shard_needle`, which errors "ec shard N not available
// locally" for any interval held by a peer. Every such error was mapped to 500
// and no `.ecj` tombstone was written, so on a standard 10+4 spread across 14
// servers no HTTP delete of an EC needle could ever succeed.
//
// A single node can exercise that path without peers: mount 13 of the 14
// shards, leaving out the one holding the needle's interval. The distributed
// reader seeds its Reed-Solomon buffers from locally mounted siblings (Phase 0
// in `store_ec.rs`), so with >= 10 survivors it reconstructs without any peer
// fan-out — while the local-only read still fails outright.
// ============================================================================
#[tokio::test]
async fn delete_on_ec_volume_succeeds_when_the_needles_shard_is_not_mounted() {
use seaweed_volume::storage::erasure_coding::ec_encoder::write_ec_files;
use seaweed_volume::storage::erasure_coding::ec_shard::ShardId;
use seaweed_volume::storage::needle::needle::{FileId, Needle};
use seaweed_volume::storage::types::{Cookie, NeedleId};
use seaweed_volume::storage::volume::{Volume, VolumeSpec};
let (state, tmp) = test_state();
let dir = tmp.path().to_str().unwrap();
let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let nid = NeedleId(0x5c);
let cookie = Cookie(0x0badc0de);
// Build regular volume 4, then EC-encode it. The volume is standalone and
// never registered in the store, so afterwards it exists only as shards.
{
let mut v = Volume::new(
dir,
dir,
VolumeId(4),
NeedleMapKind::InMemory,
&VolumeSpec::default(),
)
.unwrap();
let mut n = Needle {
id: nid,
cookie,
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
v.close();
}
write_ec_files(dir, dir, "", VolumeId(4), 10, 4).unwrap();
// Mount shards 1..=13 only. A needle at .dat offset 0 lives in shard 0's
// first small block, so the interval this delete needs is deliberately the
// one shard that is absent.
{
let mut store = state.store.write().unwrap();
let shard_ids: Vec<ShardId> = (1..14).collect();
store.mount_ec_shards(VolumeId(4), "", &shard_ids).unwrap();
}
let fid = FileId::new(VolumeId(4), nid, cookie).to_string();
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/{}", fid))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::ACCEPTED,
"DELETE of an EC needle must reconstruct through the distributed \
reader instead of failing 500 on a non-local shard"
);
// The tombstone must actually have landed: a later GET is a 404.
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.uri(format!("/{}", fid))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"the delete must have been journalled, not just answered 202"
);
}
// ============================================================================
// Hostile response-header override params must not panic the handler
//
// The `response-*` query params are attacker-controlled and were inserted with
// `parse().unwrap()`. `%0A` decodes to a newline, `HeaderValue::from_str`
// rejects it, and the unwrap panicked the connection task — unauthenticated.
// The override must simply be skipped.
// ============================================================================
#[tokio::test]
async fn hostile_response_header_overrides_are_skipped_not_panicked() {
let (state, _tmp) = test_state();
let uri = "/1,01637037d6";
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.body(Body::from(b"payload".to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
// One request per override param, each carrying a raw newline.
for param in [
"response-cache-control",
"response-content-encoding",
"response-expires",
"response-content-language",
"response-content-disposition",
"response-content-type",
] {
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.uri(format!("{}?{}=%0Aevil", uri, param))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{} with a newline must be ignored, not panic",
param
);
assert_eq!(body_bytes(response).await, b"payload".to_vec());
}
}
// ============================================================================
// Non-ASCII in the fid and in ?ttl= must be rejected, not panic
//
// `parse_needle_id_cookie` split the hex by BYTE offset and `TTL::read` took
// the unit as the last BYTE, so a multi-byte character split inside itself.
// Both are reachable unauthenticated from the request line / query string.
// ============================================================================
#[tokio::test]
async fn non_ascii_fid_and_ttl_are_rejected_not_panicked() {
let (state, _tmp) = test_state();
// A fid whose hex part is multi-byte UTF-8.
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.uri("/1,%C3%A9%C3%A9%C3%A9%C3%A9a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_client_error() || response.status().is_server_error(),
"non-ASCII fid must produce an error status, got {}",
response.status()
);
// A TTL whose unit character is multi-byte. The upload path does
// `TTL::read(..).ok()`, so *any* unparseable TTL is simply dropped and the
// write succeeds — the point here is that a non-ASCII one now takes that
// same road instead of panicking. Assert it matches an ASCII-invalid TTL
// rather than inventing a stricter contract than the handler has.
let mut statuses = Vec::new();
// Distinct needle ids: reusing one id with a different cookie is a
// cookie-mismatch overwrite, which would mask what this test measures.
for (fid, ttl) in [("/1,03637037d7", "5%C3%A9"), ("/1,04637037d8", "5z")] {
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{}?ttl={}", fid, ttl))
.body(Body::from(b"x".to_vec()))
.unwrap(),
)
.await
.unwrap();
statuses.push(response.status());
}
assert_eq!(
statuses[0], statuses[1],
"a non-ASCII ttl must behave like any other invalid ttl, not panic"
);
}
+210 -111
View File
@@ -394,6 +394,28 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -679,7 +701,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower",
"tower 0.5.3",
"tracing",
]
@@ -834,13 +856,13 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.9"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
@@ -853,13 +875,14 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -867,17 +890,19 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.6"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
@@ -1940,7 +1965,7 @@ dependencies = [
"indexmap 2.14.0",
"itertools",
"parking_lot",
"petgraph",
"petgraph 0.8.3",
"tokio",
]
@@ -2768,7 +2793,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.5",
"tokio",
"tower-service",
"tracing",
@@ -3224,9 +3249,9 @@ dependencies = [
"object_store",
"permutation",
"pin-project",
"prost",
"prost-build",
"prost-types",
"prost 0.14.4",
"prost-build 0.14.4",
"prost-types 0.14.4",
"rand 0.9.5",
"rayon",
"roaring",
@@ -3332,7 +3357,7 @@ dependencies = [
"num_cpus",
"object_store",
"pin-project",
"prost",
"prost 0.14.4",
"quick_cache",
"rand 0.9.5",
"roaring",
@@ -3373,8 +3398,8 @@ dependencies = [
"lance-datagen",
"log",
"pin-project",
"prost",
"prost-build",
"prost 0.14.4",
"prost-build 0.14.4",
"tokio",
"tracing",
]
@@ -3436,8 +3461,8 @@ dependencies = [
"log",
"lz4",
"num-traits",
"prost",
"prost-build",
"prost 0.14.4",
"prost-build 0.14.4",
"rand 0.9.5",
"strum",
"tokio",
@@ -3472,9 +3497,9 @@ dependencies = [
"log",
"num-traits",
"object_store",
"prost",
"prost-build",
"prost-types",
"prost 0.14.4",
"prost-build 0.14.4",
"prost-types 0.14.4",
"tokio",
"tracing",
]
@@ -3529,9 +3554,9 @@ dependencies = [
"ndarray",
"num-traits",
"object_store",
"prost",
"prost-build",
"prost-types",
"prost 0.14.4",
"prost-build 0.14.4",
"prost-types 0.14.4",
"rand 0.9.5",
"rand_distr",
"rangemap",
@@ -3565,7 +3590,7 @@ dependencies = [
"lance-core",
"lance-io",
"lance-select",
"prost-types",
"prost-types 0.14.4",
"roaring",
"serde",
"serde_json",
@@ -3599,7 +3624,7 @@ dependencies = [
"opendal",
"path_abs",
"pin-project",
"prost",
"prost 0.14.4",
"rand 0.9.5",
"serde",
"tempfile",
@@ -3694,9 +3719,9 @@ dependencies = [
"lance-select",
"log",
"object_store",
"prost",
"prost-build",
"prost-types",
"prost 0.14.4",
"prost-build 0.14.4",
"prost-types 0.14.4",
"rand 0.9.5",
"rangemap",
"roaring",
@@ -3913,9 +3938,9 @@ dependencies = [
[[package]]
name = "matchit"
version = "0.8.4"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matrixmultiply"
@@ -4386,6 +4411,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
]
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -4528,6 +4563,16 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.4"
@@ -4535,7 +4580,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
"prost-derive 0.14.4",
]
[[package]]
name = "prost-build"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"once_cell",
"petgraph 0.7.1",
"prettyplease",
"prost 0.13.5",
"prost-types 0.13.5",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
@@ -4548,17 +4613,28 @@ dependencies = [
"itertools",
"log",
"multimap",
"petgraph",
"petgraph 0.8.3",
"prettyplease",
"prost",
"prost-types",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"prost 0.14.4",
"prost-types 0.14.4",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
@@ -4572,13 +4648,22 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost 0.13.5",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
"prost 0.14.4",
]
[[package]]
@@ -4645,26 +4730,6 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"memchr",
"unicase",
]
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -4710,7 +4775,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.5",
"thiserror 2.0.20",
"tokio",
"tracing",
@@ -4749,7 +4814,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -4781,13 +4846,24 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
@@ -4802,6 +4878,16 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -4812,6 +4898,15 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -5065,7 +5160,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http",
"tower-service",
"url",
@@ -5104,7 +5199,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http",
"tower-service",
"url",
@@ -5214,6 +5309,15 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -5329,13 +5433,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seaweed-common"
version = "0.1.0"
dependencies = [
"rustls",
]
[[package]]
name = "seaweed-worker-core"
version = "0.1.0"
@@ -5344,15 +5441,13 @@ dependencies = [
"async-trait",
"axum",
"prometheus",
"prost",
"prost-types",
"prost 0.13.5",
"prost-types 0.13.5",
"protoc-bin-vendored",
"seaweed-common",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
"tonic-prost-build",
"tonic-build",
"tracing",
]
@@ -5628,6 +5723,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -5922,7 +6027,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.5",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -5976,10 +6081,11 @@ dependencies = [
[[package]]
name = "tonic"
version = "0.14.6"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
dependencies = [
"async-stream",
"async-trait",
"axum",
"base64 0.22.1",
@@ -5993,12 +6099,13 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"socket2",
"sync_wrapper",
"prost 0.13.5",
"rustls-pemfile",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
@@ -6006,41 +6113,36 @@ dependencies = [
[[package]]
name = "tonic-build"
version = "0.14.6"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build 0.13.5",
"prost-types 0.13.5",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn 2.0.119",
"tempfile",
"tonic-build",
"futures-core",
"futures-util",
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.7",
"slab",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -6051,12 +6153,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.14.0",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -6079,7 +6178,7 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"url",
@@ -6457,7 +6556,7 @@ dependencies = [
"lance-table",
"prometheus",
"reqwest 0.12.28",
"seaweed-common",
"rustls",
"seaweed-worker-core",
"seaweed-worker-sort",
"serde",
+8 -8
View File
@@ -17,24 +17,24 @@ edition = "2024"
rust-version = "1.94.1"
[workspace.lints.clippy]
# Every RPC path returns tonic::Status (176 bytes). Boxing it would change
# every handler signature for no gain, so the large-Err lint is off.
result_large_err = "allow"
# Protobuf message literals keep `..Default::default()` on purpose: it is
# what lets a proto gain a field without touching every constructor.
needless_update = "allow"
# Every `unsafe` block states its precondition, right above the block.
undocumented_unsafe_blocks = "warn"
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
prost = "0.14"
prost-types = "0.14"
prost = "0.13"
prost-types = "0.13"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tonic = { version = "0.14", features = ["tls-aws-lc"] }
tonic-prost = "0.14"
tonic = { version = "0.12", features = ["tls"] }
# Already in the tree via tonic; named here so the metrics server can use them.
axum = "0.8"
axum = "0.7"
prometheus = { version = "0.13", default-features = false }
tonic-prost-build = "0.14"
tonic-build = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-4
View File
@@ -9,10 +9,6 @@ one.
crates/core the contract: stream, handshake, heartbeat, registry, config forms
crates/lance maintenance jobs for Lance tables, and a binary
It also depends on `../seaweed-common`, a small crate outside this workspace
holding the few helpers the Rust volume server needs identically — the
HTTP<->gRPC address rule and the rustls provider choice.
`core` knows nothing about any job. A second worker is a new crate beside
`lance` that depends on it, not a fork of the protocol.
+1 -5
View File
@@ -9,9 +9,6 @@ description = "SeaweedFS plugin.proto worker contract"
name = "seaweed_worker_core"
[dependencies]
# Helpers the Rust volume server needs as well. A path dependency because
# the two trees are separate cargo workspaces with no common root manifest.
seaweed-common = { path = "../../../seaweed-common" }
anyhow.workspace = true
async-trait.workspace = true
prost.workspace = true
@@ -19,13 +16,12 @@ prost-types.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tonic.workspace = true
tonic-prost.workspace = true
axum.workspace = true
prometheus.workspace = true
tracing.workspace = true
[build-dependencies]
tonic-prost-build.workspace = true
tonic-build.workspace = true
# Ships protoc with the build so neither CI nor a developer needs a system
# install, and so the version is pinned rather than whatever the platform's
# package manager happens to carry. The same crate seaweed-volume uses.
+1 -1
View File
@@ -14,7 +14,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Compiled straight out of the Go tree, the way seaweed-volume already reads
// filer.proto, so the contract cannot drift from a vendored copy.
tonic_prost_build::configure()
tonic_build::configure()
// The server half is only for tests, which stand up a fake admin.
.build_server(true)
.build_client(true)
+22 -12
View File
@@ -5,15 +5,32 @@
//! instead fails as "frame with invalid size", which reads like a protocol bug
//! rather than a wrong port, so getting this right is worth its own module.
//! Mirrors pb.ServerToGrpcAddress in weed/pb/grpc_client_server.go.
//!
//! The rule itself now lives in `seaweed_common::address`, shared with the Rust
//! volume server, which had its own copy of it. What stays here is the `Option`
//! shape this crate's callers expect, and the tests that pin it.
const GRPC_PORT_OFFSET: u16 = 10000;
/// Converts `host:port` to the gRPC address, and accepts the explicit
/// `host:port.grpcPort` form the Go side also understands.
pub fn server_to_grpc_address(server: &str) -> Option<String> {
seaweed_common::address::to_grpc_address(server).ok()
let (host, port_part) = server.rsplit_once(':')?;
// "port.grpcPort" states the gRPC port outright.
if let Some((_, grpc_port)) = port_part.split_once('.')
&& let Ok(port) = grpc_port.parse::<u16>()
{
return Some(join_host_port(host, port));
}
let port: u16 = port_part.parse().ok()?;
Some(join_host_port(host, port.checked_add(GRPC_PORT_OFFSET)?))
}
fn join_host_port(host: &str, port: u16) -> String {
// An IPv6 literal has to keep its brackets or the port reads as part of it.
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
#[cfg(test)]
@@ -53,11 +70,4 @@ mod tests {
assert!(server_to_grpc_address("localhost").is_none());
assert!(server_to_grpc_address("localhost:notaport").is_none());
}
#[test]
fn rejects_a_dotted_form_whose_http_port_is_not_a_port() {
// Tightened by the move to seaweed-common: this copy used to ignore the
// HTTP port of the dotted form and answer Some("host:18080").
assert!(server_to_grpc_address("host:abc.18080").is_none());
}
}
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use crate::pb::{
ConfigField, ConfigFieldType, ConfigForm, ConfigSection, ConfigValue, config_value::Kind,
config_value::Kind, ConfigField, ConfigFieldType, ConfigForm, ConfigSection, ConfigValue,
};
pub fn int_value(value: i64) -> ConfigValue {
+15 -24
View File
@@ -270,10 +270,10 @@ impl Metrics {
/// logged rather than fatal: a worker that cannot publish metrics should still
/// do its work.
pub async fn serve(metrics: Metrics, addr: SocketAddr) -> Result<()> {
use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::get;
use axum::Router;
let app = Router::new()
.route("/health", get(|| async { StatusCode::OK }))
@@ -355,37 +355,28 @@ mod tests {
metrics.stream_connected();
assert!(metrics.is_ready());
assert!(
metrics
.gather()
.unwrap()
.contains("SeaweedFS_worker_connected 1")
);
assert!(metrics
.gather()
.unwrap()
.contains("SeaweedFS_worker_connected 1"));
metrics.stream_ended("closed");
assert!(!metrics.is_ready());
assert!(
metrics
.gather()
.unwrap()
.contains("SeaweedFS_worker_connected 0")
);
assert!(
metrics
.gather()
.unwrap()
.contains("SeaweedFS_worker_stream_events_total{event=\"closed\"} 1")
);
assert!(metrics
.gather()
.unwrap()
.contains("SeaweedFS_worker_connected 0"));
assert!(metrics
.gather()
.unwrap()
.contains("SeaweedFS_worker_stream_events_total{event=\"closed\"} 1"));
}
#[test]
fn build_info_names_the_worker() {
let text = metrics().gather().expect("gather");
assert!(
text.contains(
"SeaweedFS_worker_build_info{version=\"0.1.0\",worker_id=\"worker-1\"} 1"
)
);
assert!(text
.contains("SeaweedFS_worker_build_info{version=\"0.1.0\",worker_id=\"worker-1\"} 1"));
}
// A format's own numbers land on the same registry, so one endpoint serves
+2 -2
View File
@@ -2,8 +2,8 @@ use anyhow::Result;
use tokio::sync::mpsc;
use crate::pb::{
ActivityEvent, DetectionComplete, DetectionProposals, JobCompleted, JobProgressUpdate,
WorkerObservations, WorkerToAdminMessage, worker_to_admin_message::Body,
worker_to_admin_message::Body, ActivityEvent, DetectionComplete, DetectionProposals,
JobCompleted, JobProgressUpdate, WorkerObservations, WorkerToAdminMessage,
};
/// Replies to one detection request.
+5 -5
View File
@@ -1,8 +1,8 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use tokio::sync::{Semaphore, mpsc};
use anyhow::{anyhow, Context, Result};
use tokio::sync::{mpsc, Semaphore};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
use tracing::{info, warn};
@@ -10,11 +10,11 @@ use tracing::{info, warn};
use crate::config::WorkerOptions;
use crate::metrics::Metrics;
use crate::pb::{
ConfigSchemaResponse, ExecuteJobRequest, JobCompleted, ObjectPreviewResponse, PreviewRow,
RequestObjectPreview, RunDetectionRequest, RunningWork, WorkerHeartbeat, WorkerHello,
admin_to_worker_message::Body as AdminBody,
plugin_control_service_client::PluginControlServiceClient,
worker_to_admin_message::Body as WorkerBody,
worker_to_admin_message::Body as WorkerBody, ConfigSchemaResponse, ExecuteJobRequest,
JobCompleted, ObjectPreviewResponse, PreviewRow, RequestObjectPreview, RunDetectionRequest,
RunningWork, WorkerHeartbeat, WorkerHello,
};
use crate::registry::Registry;
use crate::senders::{MeteredSender, StreamSender};
+1 -4
View File
@@ -16,10 +16,6 @@ name = "weed-worker"
path = "src/main.rs"
[dependencies]
# Helpers the Rust volume server needs as well. A path dependency because
# the two trees are separate cargo workspaces with no common root manifest.
# It also carries the rustls requirement this crate used to state itself.
seaweed-common = { path = "../../../seaweed-common" }
seaweed-worker-core = { path = "../core" }
seaweed-worker-sort = { path = "../sort" }
prometheus.workspace = true
@@ -40,6 +36,7 @@ anyhow.workspace = true
async-trait.workspace = true
clap = { version = "4", features = ["derive", "env"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rustls = "0.23"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio.workspace = true
@@ -7,4 +7,4 @@
pub mod namespace;
pub use namespace::{NamespaceClient, TableDescription, parse_id};
pub use namespace::{parse_id, NamespaceClient, TableDescription};
+1 -1
View File
@@ -8,8 +8,8 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use lance::dataset::Dataset;
use lance::dataset::builder::DatasetBuilder;
use lance::dataset::Dataset;
use crate::catalog::{NamespaceClient, TableDescription};
@@ -1,9 +1,9 @@
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use lance::dataset::cleanup::{CleanupPolicy, cleanup_old_versions};
use lance::dataset::cleanup::{cleanup_old_versions, CleanupPolicy};
use seaweed_worker_core::config_form::{form, int_or, int_value, number_field};
use seaweed_worker_core::pb::{
ConfigValue, DetectionComplete, DetectionProposals, ExecuteJobRequest, JobCompleted,
@@ -13,7 +13,7 @@ use seaweed_worker_core::pb::{
use seaweed_worker_core::{DetectionSender, ExecutionSender, JobHandler};
use tracing::warn;
use crate::catalog::{NamespaceClient, parse_id};
use crate::catalog::{parse_id, NamespaceClient};
use crate::dataset;
use crate::jobs::{clamp, string_list, table_id};
@@ -1,8 +1,8 @@
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use lance::dataset::optimize::{CompactionOptions, compact_files};
use lance::dataset::optimize::{compact_files, CompactionOptions};
use seaweed_worker_core::config_form::{form, int_or, int_value, number_field, string_value};
use seaweed_worker_core::pb::{
ConfigValue, DetectionComplete, DetectionProposals, ExecuteJobRequest, JobCompleted,
@@ -12,9 +12,9 @@ use seaweed_worker_core::pb::{
use seaweed_worker_core::{DetectionSender, ExecutionSender, JobHandler};
use tracing::warn;
use crate::catalog::{NamespaceClient, parse_id};
use crate::catalog::{parse_id, NamespaceClient};
use crate::dataset;
use crate::jobs::{FORMAT, clamp, observation, string_list, table_id};
use crate::jobs::{clamp, observation, string_list, table_id, FORMAT};
pub const JOB_TYPE: &str = "lance_compact";

Some files were not shown because too many files have changed in this diff Show More