Compare commits

..
Author SHA1 Message Date
Chris Lu 0a730efea6 remote sync: keep the event entry intact for IF_ENTRY_EQUAL 2026-09-24 16:25:00 -07:00
Chris Lu b6dd48403e filer.remote.sync: classify refused stamps by gRPC status only
A FailedPrecondition substring in an unrelated error would have been
swallowed as a skipped stamp; status.FromError already unwraps.
2026-09-23 17:07:02 -07:00
Chris Lu 8cc3e299fc filer: compare IF_ENTRY_EQUAL against the normalized expected entry
FindEntry grows FileSize to the chunk extent, so a raw event entry with
FileSize still zero failed the condition on an unchanged file and the
stamp was skipped, letting a replay upload the object again.
2026-09-23 17:07:02 -07:00
Chris Lu 92bd112cfd filer: route conditional UpdateEntry to the entry's owner filer
Two filers locking the same path locally could still pass a stale
condition on the non-owner while the owner's entry had moved on. When a
condition or expected_extended precondition is set, forward the request
to the entry's owner the same way conditional CreateEntry does, with
is_moved bounding the hop.
2026-09-23 16:34:38 -07:00
Chris Lu 4f1178e0ba filer.remote.sync: stamp entries with IF_ENTRY_EQUAL so stale inline content or metadata cannot be restored
The IF_CHUNKS_EQUAL guard compared only the chunk fid multiset, so a
rewrite that touched inline content or metadata alone still compared
equal and the stale snapshot overwrote the live entry. The new clause
compares the whole stored entry against the event's entry under the
same path lock.
2026-09-23 16:34:29 -07:00
jsas 071d9d2f3c filer.remote.sync: stamp entries with IF_CHUNKS_EQUAL so a stale write-back cannot delete live chunks
updateLocalEntry records the RemoteEntry stamp after an upload by writing the
event's entry back with UpdateEntry. The filer deletes every stored chunk
absent from an updated entry, so when the file was rewritten while its upload
was in flight (or the event is a replay), the stale snapshot deletes the
rewrite's chunks: the entry then points at the new fid with no needle behind
it, and the rewrite's own upload fails and is skipped as superseded.

The stamp write now carries WriteCondition IF_CHUNKS_EQUAL over the event's
chunk fids, evaluated by the filer under the path lock. A refused stamp means
the filer moved past this event; the superseding event follows in the log and
stamps the current entry, so the refusal is logged and skipped like a
superseded upload.

Reproduction: weed server -filer plus a weed server -s3 remote, remote.mount,
filer.remote.sync; hold the remote (docker pause) so one upload stays in
flight, rewrite the file through the filer, unpause. Before: the entry's chunk
is 404 on every volume server. After: the stale stamp is refused, the rewrite's
chunk stays live and reads back after a vacuum.
2026-09-23 16:11:48 -07:00
244 changed files with 3729 additions and 25459 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.2
uses: github/codeql-action/init@v4.38.1
# Override language selection by uncommenting this and choosing your languages
with:
languages: go
@@ -35,7 +35,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v4.38.2
uses: github/codeql-action/autobuild@v4.38.1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -49,4 +49,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.38.2
uses: github/codeql-action/analyze@v4.38.1
+5 -9
View File
@@ -156,7 +156,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
platform: [amd64, arm64, arm, 386, ppc64le, s390x]
platform: [amd64, arm64, arm, 386]
variant: ${{ fromJSON(needs.setup.outputs.variants) }}
steps:
@@ -405,7 +405,7 @@ jobs:
output: trivy-results.sarif
exit-code: '0'
- name: Upload Trivy scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v4.38.2
uses: github/codeql-action/upload-sarif@v4.38.1
if: always()
with:
sarif_file: trivy-results.sarif
@@ -505,9 +505,7 @@ jobs:
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-amd64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-ppc64le \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-s390x
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386
# The copy and the signature below use the digest this run pushed, not whatever the tag points at by then.
DIGEST=$(jq -er '."containerimage.descriptor".digest' /tmp/manifest.json)
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
@@ -551,15 +549,13 @@ jobs:
echo "Using skopeo to copy..."
retry_with_backoff skopeo copy --all docker://ghcr.io/chrislusf/seaweedfs@${DIGEST} docker://chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}
else
echo "Using docker buildx imagetools (pulling 6 images from Docker Hub)..."
echo "Using docker buildx imagetools (pulling 4 images from Docker Hub)..."
# Fallback: create manifest directly on Docker Hub (pulls from Docker Hub - rate limited)
retry_with_backoff docker buildx imagetools create -t chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-amd64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-ppc64le \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-s390x
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386
fi
- name: Sign
@@ -149,16 +149,12 @@ jobs:
- { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/arm64, arch: arm64, runner: ubuntu-24.04-arm, qemu: false }
- { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/arm/v7, arch: armv7, runner: ubuntu-latest, qemu: true }
- { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/386, arch: i386, runner: ubuntu-latest, qemu: false }
- { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/ppc64le, arch: ppc64le, runner: ubuntu-latest, qemu: true }
- { variant: normal, tag_suffix: "", dockerfile: ./docker/Dockerfile.go_build, build_args: "", rust_variant: normal, platform: linux/s390x, arch: s390x, runner: ubuntu-latest, qemu: true }
# Large disk - multi-arch
- { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/amd64, arch: amd64, runner: ubuntu-latest, qemu: false }
- { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/arm64, arch: arm64, runner: ubuntu-24.04-arm, qemu: false }
- { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/arm/v7, arch: armv7, runner: ubuntu-latest, qemu: true }
- { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/386, arch: i386, runner: ubuntu-latest, qemu: false }
- { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/ppc64le, arch: ppc64le, runner: ubuntu-latest, qemu: true }
- { variant: large_disk, tag_suffix: _large_disk, dockerfile: ./docker/Dockerfile.go_build, build_args: TAGS=5BytesOffset, rust_variant: large-disk, platform: linux/s390x, arch: s390x, runner: ubuntu-latest, qemu: true }
# Full tags - multi-arch
- { variant: full, tag_suffix: _full, dockerfile: ./docker/Dockerfile.go_build, build_args: "TAGS=elastic,gocdk,rclone,sqlite,tarantool,tikv,ydb", rust_variant: normal, platform: linux/amd64, arch: amd64, runner: ubuntu-latest, qemu: false }
@@ -460,7 +456,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v4.38.2
uses: github/codeql-action/upload-sarif@v4.38.1
with:
sarif_file: trivy-results.sarif
category: trivy-${{ matrix.variant }}
-111
View File
@@ -439,117 +439,6 @@ jobs:
path: test/s3tables/catalog_clickhouse/test-output.log
retention-days: 3
olake-iceberg-catalog-tests:
name: OLake Iceberg Catalog Integration Tests (${{ matrix.tag }})
runs-on: ubuntu-22.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# Pinned baseline, and latest so new OLake releases are exercised
# without a code change. OLake's Iceberg writer is a Java sidecar
# whose Iceberg version moves independently of the Go release, so
# the latest leg is the one that catches library drift.
- olake-image: olakego/source-postgres:v0.10.1
tag: "v0.10.1"
- olake-image: olakego/source-postgres:latest
tag: latest
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
id: go
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull images
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull ${{ matrix.olake-image }}
pull postgres:16
pull python:3.11-slim
- name: Run go mod tidy
run: go mod tidy
- name: Install SeaweedFS
run: |
go install -buildvcs=false ./weed
- name: Run OLake Iceberg Catalog Integration Tests
timeout-minutes: 25
working-directory: test/s3tables/catalog_olake
env:
OLAKE_IMAGE: ${{ matrix.olake-image }}
run: |
set -x
set -o pipefail
echo "=== System Information ==="
uname -a
free -h
df -h
docker info
echo "=== Starting OLake Iceberg Catalog Tests ==="
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
echo "OLake Iceberg catalog integration tests failed"
exit 1
}
# The suite skips itself when Docker is unavailable, so a green job is not
# by itself evidence that anything ran. Assert execution explicitly.
- name: Assert the suite actually ran
working-directory: test/s3tables/catalog_olake
run: |
log=test-output.log
if [ ! -f "$log" ]; then
echo "::error::no test-output.log; the suite did not run"
exit 1
fi
passes=$(grep -c '^--- PASS' "$log" || true)
skips=$(grep -c '^--- SKIP' "$log" || true)
echo "top-level PASS=$passes SKIP=$skips"
if [ "$skips" -gt 0 ]; then
echo "::error::the OLake suite skipped $skips top-level test(s); the environment it needs was not provisioned, so this job proves nothing"
grep '^--- SKIP' "$log" | head -20
exit 1
fi
if [ "$passes" -lt 1 ]; then
echo "::error::the OLake suite recorded no passing top-level test"
exit 1
fi
- name: Show test output on failure
if: failure()
working-directory: test/s3tables/catalog_olake
run: |
echo "=== Test Output ==="
if [ -f test-output.log ]; then
tail -200 test-output.log
fi
echo "=== Process information ==="
ps aux | grep -E "(weed|test|docker|olake|postgres)" || true
echo "=== Containers ==="
docker ps -a | head -30 || true
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: olake-iceberg-catalog-test-logs-${{ matrix.tag }}
path: test/s3tables/catalog_olake/test-output.log
retention-days: 3
polaris-integration-tests:
name: Polaris Integration Tests
runs-on: ubuntu-22.04
-4
View File
@@ -289,7 +289,6 @@ jobs:
s3tests/functional/test_s3.py::test_object_write_check_etag \
s3tests/functional/test_s3.py::test_object_write_cache_control \
s3tests/functional/test_s3.py::test_object_write_expires \
s3tests/functional/test_s3.py::test_object_content_encoding_aws_chunked \
s3tests/functional/test_s3.py::test_object_write_read_update_read_delete \
s3tests/functional/test_s3.py::test_object_metadata_replaced_on_put \
s3tests/functional/test_s3.py::test_object_write_file \
@@ -312,7 +311,6 @@ jobs:
s3tests/functional/test_s3.py::test_get_object_ifmodifiedsince_good \
s3tests/functional/test_s3.py::test_get_object_ifmodifiedsince_failed \
s3tests/functional/test_s3.py::test_get_object_ifunmodifiedsince_failed \
s3tests/functional/test_s3.py::test_get_checksum_object_attributes \
s3tests/functional/test_s3.py::test_bucket_head \
s3tests/functional/test_s3.py::test_bucket_head_notexist \
s3tests/functional/test_s3.py::test_object_raw_authenticated \
@@ -1151,7 +1149,6 @@ jobs:
s3tests/functional/test_s3.py::test_object_write_check_etag \
s3tests/functional/test_s3.py::test_object_write_cache_control \
s3tests/functional/test_s3.py::test_object_write_expires \
s3tests/functional/test_s3.py::test_object_content_encoding_aws_chunked \
s3tests/functional/test_s3.py::test_object_write_read_update_read_delete \
s3tests/functional/test_s3.py::test_object_metadata_replaced_on_put \
s3tests/functional/test_s3.py::test_object_write_file \
@@ -1174,7 +1171,6 @@ jobs:
s3tests/functional/test_s3.py::test_get_object_ifmodifiedsince_good \
s3tests/functional/test_s3.py::test_get_object_ifmodifiedsince_failed \
s3tests/functional/test_s3.py::test_get_object_ifunmodifiedsince_failed \
s3tests/functional/test_s3.py::test_get_checksum_object_attributes \
s3tests/functional/test_s3.py::test_bucket_head \
s3tests/functional/test_s3.py::test_bucket_head_notexist \
s3tests/functional/test_s3.py::test_object_raw_authenticated \
-1
View File
@@ -21,7 +21,6 @@ One `weed` binary serves an S3 object store, a POSIX file system, and a lakehous
- [Download Binaries for different platforms](https://github.com/seaweedfs/seaweedfs/releases/latest)
- [Wiki Documentation](https://github.com/seaweedfs/seaweedfs/wiki)
- [HTTP REST API](REST_API.md) for the filer, master, and volume servers
- Community: [Slack](https://join.slack.com/t/seaweedfs/shared_invite/enQtMzI4MTMwMjU2MzA3LTEyYzZmZWYzOGQ3MDJlZWMzYmI0OTE4OTJiZjJjODBmMzUxNmYwODg0YjY3MTNlMjBmZDQ1NzQ5NDJhZWI2ZmY), [Twitter](https://twitter.com/SeaweedFS), [Telegram](https://t.me/Seaweedfs), [Reddit](https://www.reddit.com/r/SeaweedFS/), [Mailing List](https://groups.google.com/d/forum/seaweedfs)
- [SeaweedFS White Paper](https://github.com/seaweedfs/seaweedfs/wiki/SeaweedFS_Architecture.pdf) and introduction slides: [2025.5](https://docs.google.com/presentation/d/1tdkp45J01oRV68dIm4yoTXKJDof-EhainlA0LMXexQE/edit?usp=sharing), [2021.5](https://docs.google.com/presentation/d/1DcxKWlINc-HNCjhYeERkpGXXm6nTCES8mi2W5G0Z4Ts/edit?usp=sharing), [2019.3](https://www.slideshare.net/chrislusf/seaweedfs-introduction)
-344
View File
@@ -1,344 +0,0 @@
# SeaweedFS HTTP REST API
SeaweedFS exposes three HTTP surfaces:
| Service | Default port | Addressing |
|---------|--------------|------------|
| Filer | 8888 | File system paths (`/dir/name`) |
| Master | 9333 | File id assignment and cluster topology |
| Volume server | 8080 | File content by file id (`vid,fid`) |
Most clients only need the filer API (paths) or the S3 API. The master and
volume APIs are the lower-level blob store interface.
Conventions applying to all three:
- Responses are JSON unless noted otherwise. Append `&pretty=y` to pretty-print.
- A file id (`fid`) has the form `volumeId,fileKeyCookie`, e.g. `3,01637037d6`.
An optional suffix selects a reserved id from a `count` assignment
(`3,01637037d6_1`, `_2`, ...), and an optional extension
(`3,01637037d6.jpg`) sets the content type on reads.
- `replication` is a 3-digit replica placement `xyz`: `x` copies in other
data centers, `y` on other racks in the same data center, `z` on other
volume servers on the same rack. `000` = no replication, `001` = one copy
on the same rack, `010` = one copy on a different rack, `100` = one copy in
another data center, `200` = two copies in two other data centers, `110` =
one copy in another data center plus one on another rack.
- `ttl` units: `m` minute, `h` hour, `d` day, `w` week, `M` month, `y` year.
## Filer API (port 8888)
The filer presents a POSIX-like namespace over the volume servers.
### Upload a file
```bash
# PUT the raw body to the target path
curl -T /home/chris/myphoto.jpg "http://localhost:8888/dir/myphoto.jpg"
# or POST as multipart form (the part filename becomes the entry name)
curl -F file=@/home/chris/myphoto.jpg "http://localhost:8888/dir/"
```
Response `201 Created`:
```json
{"name":"myphoto.jpg","size":43234,"eTag":"0x6c656...","mtime":"...","chunks":[...]}
```
Query parameters:
| Parameter | Description | Default |
|-----------|-------------|---------|
| `collection` | collection name | empty |
| `replication` | replica placement code | filer default |
| `ttl` | file expiration, e.g. `3d` | never |
| `disk` | disk type to store on | filer default |
| `fsync` | `true` fsyncs on the volume server | false |
| `dataCenter` | preferred data center | empty |
| `rack` | preferred rack | empty |
| `dataNode` | preferred volume server | empty |
| `saveInside` | store small content inside the metadata instead of a volume | false |
| `maxMB` | split the upload into chunks of this many MB | filer `-maxMB` |
| `mode` | unix permission bits, e.g. `0644` | `0660` |
| `op` | `append` appends to an existing file | overwrite |
| `skipCheckParentDir` | `true` skips the parent-directory existence check | false |
### Create a directory
```bash
curl -X POST "http://localhost:8888/dir/newdir/"
```
A POST to a path ending in `/` with no content creates the directory,
including missing parents.
### Read a file
```bash
curl "http://localhost:8888/dir/myphoto.jpg"
```
Supports `Range` requests (`Accept-Ranges: bytes`), `ETag`, and the
`If-None-Match` / `If-Modified-Since` conditional headers. `HEAD` returns
headers only. Entry headers stored as extended attributes are echoed back,
minus internal `Seaweed-` and `xattr-` keys.
Entry metadata instead of content:
```bash
curl "http://localhost:8888/dir/myphoto.jpg?metadata=true"
```
`metadata=true&resolveManifest=true` additionally resolves chunked-manifest
entries into their real chunk list.
### List a directory
```bash
curl -H "Accept: application/json" "http://localhost:8888/dir/?limit=10&lastFileName=a.jpg"
```
| Parameter | Description | Default |
|-----------|-------------|---------|
| `limit` | max entries per page | filer `-dirListLimit` |
| `lastFileName` | resume listing after this entry name | empty |
| `namePattern` | include only names matching the wildcard | empty |
| `namePatternExclude` | exclude names matching the wildcard | empty |
The JSON response carries `Path`, `Entries`, `Limit`, `LastFileName`,
`ShouldDisplayLoadMore`, and `EmptyFolder`. Without the `Accept` header the
filer renders its HTML browser.
### Move and copy
```bash
curl -X POST "http://localhost:8888/dir/newname.jpg?mv.from=/dir/myphoto.jpg"
curl -X POST "http://localhost:8888/dir/copy.jpg?cp.from=/dir/myphoto.jpg"
```
`mv.from` renames or moves the source to the request path (`204 No Content`).
`cp.from` copies it.
### Append
```bash
curl -T chunk2.bin "http://localhost:8888/dir/file.bin?op=append"
```
### Delete
```bash
curl -X DELETE "http://localhost:8888/dir/myphoto.jpg"
curl -X DELETE "http://localhost:8888/dir/?recursive=true"
```
| Parameter | Description | Default |
|-----------|-------------|---------|
| `recursive` | delete a non-empty directory tree | false; when the filer runs with `filer.options.recursive_delete=true`, deletes are recursive unless `recursive=false` |
| `ignoreRecursiveError` | keep deleting remaining entries after an error | false |
| `skipChunkDeletion` | remove only the metadata, keep volume data | false |
### Tagging
Tags are carried as `Seaweed-`-prefixed request headers, not query
parameters; `?tagging` selects the tagging handler and `?tagging=K1,K2`
lists the keys to remove. Header names are canonicalized on write
(`Seaweed-k1` is stored as `Seaweed-K1`), and the delete list is matched
case-sensitively against the stored names.
```bash
curl -X PUT -H "Seaweed-k1: v1" -H "Seaweed-k2: v2" "http://localhost:8888/dir/file.jpg?tagging"
curl -X DELETE "http://localhost:8888/dir/file.jpg?tagging=K1,K2"
```
### Read by file id
```bash
curl "http://localhost:8888/?proxyChunkId=3,01637037d6"
```
The filer proxies the chunk read to the right volume server, so only the
filer port needs to be exposed.
### Resumable uploads
The filer serves the [TUS protocol](https://tus.io/) for resumable uploads
(`POST`, `PATCH`, `HEAD` on upload URLs). It is enabled by default at
`/.tus`; `-tusBasePath` changes the endpoint base path.
### Health
`GET /healthz` and `GET /readyz` return `200 OK`.
## Master API (port 9333)
Write-affecting endpoints are automatically proxied to the current leader, so
any master in the quorum can serve them.
### Assign a file id
```bash
curl "http://localhost:9333/dir/assign?count=1&replication=001&collection=turbo&dataCenter=dc1&ttl=3d&disk=ssd"
{"count":1,"fid":"3,01637037d6","url":"127.0.0.1:8080","publicUrl":"localhost:8080"}
```
Upload the file content to `http://<url>/<fid>` afterwards. With `count>1`,
use `<fid>_1`, `<fid>_2`, ... for the additional ids.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `count` | file ids to reserve | 1 |
| `collection` | collection name | empty |
| `dataCenter` | preferred data center | empty |
| `rack` | preferred rack | empty |
| `dataNode` | preferred volume server | empty |
| `replication` | replica placement | master `-defaultReplication` |
| `ttl` | file expiration, e.g. `3d` | never |
| `disk` | disk type | empty |
| `dataSize` | expected file size in bytes | 0 |
| `preallocate` | bytes to preallocate for new volumes | master `-volumePreallocate` |
| `writableVolumeCount` | grow this many volumes when none are writable | master default |
| `memoryMapMaxSizeMb` | memory-mapped file size (Windows) | 0 |
### Look up a volume or file id
```bash
curl "http://localhost:9333/dir/lookup?volumeId=3"
{"locations":[{"url":"localhost:8080","publicUrl":"localhost:8080"}]}
```
| Parameter | Description | Default |
|-----------|-------------|---------|
| `volumeId` | volume id; a full `vid,fid` is accepted too | required |
| `fileId` | like `volumeId`, but also returns a write JWT when security is on | empty |
| `collection` | speeds up the lookup | empty |
| `read` | `yes` generates a read JWT instead of a write JWT | empty |
### Store a file in one call
```bash
curl -F file=@/home/chris/report.pdf "http://localhost:9333/submit?collection=turbo&replication=001"
{"fileName":"report.pdf","fid":"3,01637037d6","fileUrl":"localhost:8080/3,01637037d6","size":43234,"eTag":"0x6c656..."}
```
`POST /submit` accepts multipart file data plus the `dir/assign` placement
parameters (`count`, `collection`, `dataCenter`, `rack`, `replication`,
`ttl`, `disk`), assigns a file id, uploads to the volume server, and returns
the result.
### Redirect to a file
```bash
curl -v "http://localhost:9333/3,01637037d6"
```
`GET /{fileId}` answers `308 Permanent Redirect` to a volume server holding
the file, preserving the query string (e.g. image-resize parameters).
### Cluster status
```bash
curl "http://localhost:9333/dir/status?pretty=y" # full topology tree
curl "http://localhost:9333/vol/status?pretty=y" # every volume on every node
curl "http://localhost:9333/collection/info?collection=turbo"
curl "http://localhost:9333/collection/info?collection=turbo&detail=true"
```
`collection/info` returns aggregated `TotalSize`, `FileCount`, `UsedSize`,
`VolumeCount`; `detail=true` splits them per volume layout.
### Grow volumes
```bash
curl "http://localhost:9333/vol/grow?count=4&replication=001&collection=turbo&ttl=5d&disk=ssd&dataCenter=dc1&rack=rack1"
{"count":4}
```
`count` is required; the placement parameters match `dir/assign`. One volume
serves one write at a time, so pre-allocated volumes raise write concurrency.
### Vacuum deleted space
```bash
curl "http://localhost:9333/vol/vacuum?garbageThreshold=0.4"
```
| Parameter | Description | Default |
|-----------|-------------|---------|
| `garbageThreshold` | minimum deleted-bytes ratio before a volume is compacted | master `-garbageThreshold` (0.3) |
Vacuuming makes a volume read-only, copies live needles to a new volume, and
swaps it in.
### Delete a collection
```bash
curl "http://localhost:9333/col/delete?collection=benchmark"
```
Deletes all volumes of the collection, including erasure-coded shards.
`204 No Content` on success.
### Health
```bash
curl -I "http://localhost:9333/healthz" # liveness
curl -I "http://localhost:9333/readyz" # readiness
curl "http://localhost:9333/" # web UI
```
## Volume server API (port 8080)
The volume server stores file content by file id. Clients normally get the
volume URL from `dir/assign` or `dir/lookup`.
### Upload
```bash
curl -F file=@/home/chris/myphoto.jpg "http://127.0.0.1:8080/3,01637037d6"
{"name":"myphoto.jpg","size":43234,"eTag":"0x6c656...","mime":"image/jpeg","contentMd5":"..."}
```
PUT or POST the body (or a multipart `file` part) to `/{vid},{fid}`.
`204 No Content` is returned when the content is unchanged. `?ts=<unix>`
sets the stored modification time.
### Read
```bash
curl "http://127.0.0.1:8080/3,01637037d6"
curl "http://127.0.0.1:8080/3,01637037d6.jpg" # sets Content-Type from the extension
```
Supports `Range` and `HEAD`. Image files can be resized server-side:
| Parameter | Description |
|-----------|-------------|
| `width`, `height` | resize bounds in pixels |
| `mode` | `fit` (contain) or `fill` (cover); omitted resizes to `width`/`height` |
| `crop_x1`, `crop_y1`, `crop_x2`, `crop_y2` | explicit crop rectangle |
| `cm` | `false` returns the chunk-manifest blob instead of resolving it |
| `readDeleted` | `true` reads soft-deleted needles |
| `collection` | passed through redirects for the right volume |
### Delete
```bash
curl -X DELETE "http://127.0.0.1:8080/3,01637037d6"
{"size":43234}
```
`?ts=<unix>` sets the deletion timestamp. Replicated volumes propagate the
delete to every replica.
### Status
```bash
curl "http://localhost:8080/status?pretty=y" # disk and volume inventory
curl -I "http://localhost:8080/healthz" # liveness/readiness
```
`OPTIONS` preflights answer CORS headers. When `-port.public` differs from
`-port`, the volume server opens a separate read-only public listener on
that port; `-publicUrl` sets the address it advertises to clients.
+7 -7
View File
@@ -14,7 +14,7 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.1.0
github.com/dustin/go-humanize v1.0.1
github.com/eapache/go-resiliency v1.6.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
github.com/eapache/queue v1.1.0 // indirect
@@ -94,7 +94,7 @@ require (
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597
golang.org/x/image v0.46.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.37.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.48.0
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.49.0 // indirect
@@ -111,7 +111,7 @@ require (
)
require (
cloud.google.com/go/kms v1.35.0
cloud.google.com/go/kms v1.34.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
@@ -154,7 +154,7 @@ require (
github.com/tarantool/go-tarantool/v3 v3.0.2
github.com/testcontainers/testcontainers-go v0.44.0
github.com/tikv/client-go/v2 v2.0.7
github.com/twmb/avro v1.9.0
github.com/twmb/avro v1.8.0
github.com/xeipuuv/gojsonschema v1.2.0
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.2
github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1
@@ -302,13 +302,13 @@ require (
cloud.google.com/go/iam v1.12.0 // indirect
cloud.google.com/go/monitoring v1.30.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
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/exporter/metric v0.57.0 // indirect
+14 -14
View File
@@ -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.35.0 h1:nJ/ktaqspx1nPM9vIcO0SHbhqCAm8nvAxL1siuVgKm0=
cloud.google.com/go/kms v1.35.0/go.mod h1:0++71pIHvJL+GmMa8K4jOWFq7gNOX3jm2PRMSJwTKJw=
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/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=
@@ -553,10 +553,10 @@ gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zum
git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
@@ -577,8 +577,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d h1:iG9B49Q218F/XxXNRM7k/vWf7MKmLIS8AcJV9cGN4nA=
@@ -953,8 +953,8 @@ github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.4.0/go.mod h1:gDXhl0OElhzYoDs
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.6.0 h1:CqGDTLtpwuWKn6Nj3uNUdflaq+/kIPsg0gfNzHton30=
github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws=
@@ -1965,8 +1965,8 @@ github.com/tsuna/gohbase v0.0.0-20201125011725-348991136365/go.mod h1:zj0GJHGvyf
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/avro v1.9.0 h1:JSiqewo3AANj7vlVCQXBsIZTA3LiiBble7Xp8T4lbtA=
github.com/twmb/avro v1.9.0/go.mod h1:X0fT1dY2xcbV4YuCE4mYro+qljHl4kUF5uA/2z1rgSk=
github.com/twmb/avro v1.8.0 h1:UMWLg+nH4P3yad5Om7yFSohYLy2RG1s7BcFFiOvmK9Q=
github.com/twmb/avro v1.8.0/go.mod h1:X0fT1dY2xcbV4YuCE4mYro+qljHl4kUF5uA/2z1rgSk=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
@@ -2354,8 +2354,8 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri
golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+2 -2
View File
@@ -1,6 +1,6 @@
apiVersion: v1
description: SeaweedFS
name: seaweedfs
appVersion: "4.48"
appVersion: "4.47"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
version: 4.48.0
version: 4.47.1
@@ -25,10 +25,6 @@ spec:
organizations:
- "SeaweedFS CA"
dnsNames:
- '{{ include "seaweedfs.fullname" . }}-admin'
- '{{ include "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}'
- '{{ include "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}.svc'
- '{{ include "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}.svc.cluster.local'
- '*.{{ include "seaweedfs.fullname" . }}-admin'
- '*.{{ include "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}'
- '*.{{ include "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}.svc'
+1016 -1005
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

-1
View File
@@ -4555,7 +4555,6 @@ dependencies = [
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"aws-smithy-runtime-api",
"aws-types",
"axum",
"base64",
-3
View File
@@ -157,9 +157,6 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
[dev-dependencies]
tempfile = "3"
# Already a transitive dependency of aws-sdk-s3 at a single locked version;
# needed directly only for the canned HttpClient in remote_storage::s3 tests.
aws-smithy-runtime-api = "1"
[build-dependencies]
tonic-prost-build = "0.14"
-6
View File
@@ -168,7 +168,6 @@ message VacuumVolumeCheckRequest {
}
message VacuumVolumeCheckResponse {
double garbage_ratio = 1;
bool disk_space_low = 4; // the volume is read-only solely because its disk is low on space — a cause compaction itself reclaims
}
message VacuumVolumeCompactRequest {
@@ -260,10 +259,6 @@ message VolumeDeleteRequest {
// when true, do not remove the cloud-tier object backing the volume.
// used for moves where another server is taking over the same .vif.
bool keep_remote_data = 3;
// when true, delete only if every needle is deleted: the volume held
// data once but nothing is live anymore. Passing either check,
// only_empty or this one, is enough to delete.
bool only_garbage = 4;
}
message VolumeDeleteResponse {
}
@@ -476,7 +471,6 @@ message VolumeEcShardsDeleteRequest {
repeated uint32 shard_ids = 3;
bool full_teardown = 4; // pre-encode cleanup: wipe every EC artifact + generation for this volume, not just shard_ids
int64 encode_ts_ns = 5; // full_teardown generation fence: delete only a disk whose .vif generation is strictly OLDER than this; preserve same-or-newer, generation 0, and an unreadable .vif. 0 => wipe-all (shell pre-encode / pre-upgrade)
uint32 delete_generations_older_than = 6; // post-commit cleanup: delete only staged <base>.*.v<N> artifacts with N strictly below this; 0 disables
}
message VolumeEcShardsDeleteResponse {
bool full_teardown_done = 1; // set by a new server that performed full_teardown; absent from an old server lets the caller detect the silent no-op
+104 -507
View File
@@ -178,22 +178,12 @@ pub struct Cli {
pub min_free_space: String,
/// Inflight upload data wait timeout of volume servers.
/// Go `time.Duration` syntax: `300ms`, `1.5h`, `2h45m`; a bare number other than `0` is rejected.
#[arg(
long = "inflightUploadDataTimeout",
default_value = "60s",
value_parser = parse_go_duration
)]
pub inflight_upload_data_timeout: std::time::Duration,
#[arg(long = "inflightUploadDataTimeout", default_value = "60s")]
pub inflight_upload_data_timeout: String,
/// Inflight download data wait timeout of volume servers.
/// Go `time.Duration` syntax: `300ms`, `1.5h`, `2h45m`; a bare number other than `0` is rejected.
#[arg(
long = "inflightDownloadDataTimeout",
default_value = "60s",
value_parser = parse_go_duration
)]
pub inflight_download_data_timeout: std::time::Duration,
#[arg(long = "inflightDownloadDataTimeout", default_value = "60s")]
pub inflight_download_data_timeout: String,
/// <experimental> if true, prevents slow reads from blocking other requests,
/// but large file read P99 latency will increase.
@@ -481,214 +471,99 @@ fn find_options_arg(args: &[String]) -> String {
String::new()
}
/// Parse a duration with Go's `time.ParseDuration` grammar, as Go's
/// `flag.Duration` flags do: `[0-9]*(\.[0-9]*)?<unit>` repeated, units
/// `ns`/`us`/`µs`/`μs`/`ms`/`s`/`m`/`h`. A bare `0` is the only unit-less
/// value; a leading sign parses in Go but a negative has no
/// `std::time::Duration`, so it is an error, not a clamp.
fn parse_go_duration(s: &str) -> Result<std::time::Duration, String> {
let orig = s;
let invalid = || format!("time: invalid duration {:?}", orig);
let mut s = s;
if let Some(rest) = s.strip_prefix('-') {
if rest.is_empty() {
return Err(invalid());
}
return Err(format!("negative duration {:?} is not supported", orig));
}
if let Some(rest) = s.strip_prefix('+') {
s = rest;
}
if s == "0" {
return Ok(std::time::Duration::ZERO);
}
if s.is_empty() {
return Err(invalid());
}
// Go bounds the total by 1<<63 (int64 nanoseconds): overflow is an error.
const MAX: u64 = 1 << 63;
let mut total: u64 = 0;
let mut bytes = s.as_bytes();
while !bytes.is_empty() {
if !(bytes[0] == b'.' || bytes[0].is_ascii_digit()) {
return Err(invalid());
}
// Consume [0-9]*
let mut i = 0;
let mut v: u64 = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
v = v
.checked_mul(10)
.and_then(|v| v.checked_add(u64::from(bytes[i] - b'0')))
.filter(|v| *v <= MAX)
.ok_or_else(invalid)?;
i += 1;
}
let pre = i != 0;
bytes = &bytes[i..];
// Consume (\.[0-9]*)?
let mut f: u64 = 0;
let mut scale: f64 = 1.0;
let mut post = false;
if let Some((b'.', rest)) = bytes.split_first() {
bytes = rest;
let mut i = 0;
let mut overflow = false;
while i < bytes.len() && bytes[i].is_ascii_digit() {
if !overflow {
// Like Go's leadingFraction, digits past ~19 are dropped.
match f
.checked_mul(10)
.and_then(|f| f.checked_add(u64::from(bytes[i] - b'0')))
.filter(|f| *f <= MAX)
{
Some(next) => {
f = next;
scale *= 10.0;
}
None => overflow = true,
}
}
i += 1;
}
post = i != 0;
bytes = &bytes[i..];
}
if !pre && !post {
return Err(invalid());
}
// Consume the unit: everything up to the next digit or '.'.
let unit_len = bytes
.iter()
.position(|c| *c == b'.' || c.is_ascii_digit())
.unwrap_or(bytes.len());
if unit_len == 0 {
return Err(format!("time: missing unit in duration {:?}", orig));
}
// The boundary is an ASCII byte or the end, so it is a char boundary.
let unit = std::str::from_utf8(&bytes[..unit_len]).map_err(|_| invalid())?;
bytes = &bytes[unit_len..];
let unit_nanos: u64 = match unit {
"ns" => 1,
"us" | "\u{00b5}s" | "\u{03bc}s" => 1_000,
"ms" => 1_000_000,
"s" => 1_000_000_000,
"m" => 60 * 1_000_000_000,
"h" => 3600 * 1_000_000_000,
_ => {
return Err(format!(
"time: unknown unit {:?} in duration {:?}",
unit, orig
));
}
};
if v > MAX / unit_nanos {
return Err(invalid());
}
v *= unit_nanos;
if f > 0 {
// Go's float math, so `1.5h` rounds the same way on both sides.
v += (f as f64 * (unit_nanos as f64 / scale)) as u64;
if v > MAX {
return Err(invalid());
}
}
total = total
.checked_add(v)
.filter(|t| *t <= MAX)
.ok_or_else(invalid)?;
}
if total > MAX - 1 {
return Err(invalid());
}
Ok(std::time::Duration::from_nanos(total))
}
/// Byte-size suffixes of Go's `util.ParseBytes`, lower-cased: SI (`kb`, `k`)
/// and IEC (`kib`, `ki`) forms, `b`, and no suffix.
fn byte_unit_multiplier(unit: &str) -> Option<u64> {
const K: u64 = 1_000;
const KI: u64 = 1 << 10;
Some(match unit {
"" | "b" => 1,
"kb" | "k" => K,
"kib" | "ki" => KI,
"mb" | "m" => K * K,
"mib" | "mi" => KI * KI,
"gb" | "g" => K * K * K,
"gib" | "gi" => KI * KI * KI,
"tb" | "t" => K * K * K * K,
"tib" | "ti" => KI * KI * KI * KI,
"pb" | "p" => K * K * K * K * K,
"pib" | "pi" => KI * KI * KI * KI * KI,
"eb" | "e" => K * K * K * K * K * K,
"eib" | "ei" => KI * KI * KI * KI * KI * KI,
_ => return None,
})
}
/// Parse a human-readable byte count with Go's `util.ParseBytes` grammar:
/// decimal number, optional whitespace, then a [`byte_unit_multiplier`]
/// suffix, case-insensitive. `10GiB` → 10737418240, `1.5TB` → 1500000000000.
///
/// Thousands commas parse as in Go but never arrive from `-minFreeSpace`,
/// which is split on commas into per-directory entries first.
fn parse_bytes(s: &str) -> Result<u64, String> {
let digits_end = s
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == ','))
.unwrap_or(s.len());
let num = s[..digits_end].replace(',', "");
let value: f64 = num
.parse()
.map_err(|e| format!("invalid byte size {:?}: {}", s, e))?;
let unit = s[digits_end..].trim().to_lowercase();
let multiplier =
byte_unit_multiplier(&unit).ok_or_else(|| format!("unhandled size name: {}", unit))?;
let bytes = value * multiplier as f64;
if bytes >= u64::MAX as f64 {
return Err(format!("too large: {}", s));
}
Ok(bytes as u64)
}
/// Parse one `-minFreeSpace` entry like Go's `util.ParseMinFreeSpace()`: a
/// plain number is a percentage in `0..=100`, anything else a byte size
/// above 100 bytes. `150` errors rather than reading as bytes.
fn parse_min_free_space(s: &str) -> Result<MinFreeSpace, String> {
/// Parse a duration string like "60s", "5m", "1h" into a std::time::Duration.
fn parse_duration(s: &str) -> std::time::Duration {
let s = s.trim();
if let Ok(percent) = s.parse::<f64>() {
// Go's range check lets NaN through; rejecting it is the one
// intentional difference.
if !(0.0..=100.0).contains(&percent) {
return Err(format!("minFreeSpace is invalid: {:?}", s));
}
return Ok(MinFreeSpace::Percent(percent));
if s.is_empty() {
return std::time::Duration::from_secs(60);
}
match parse_bytes(s) {
Ok(bytes) if bytes > 100 => Ok(MinFreeSpace::Bytes(bytes)),
_ => Err(format!("minFreeSpace is invalid: {:?}", s)),
if let Some(secs) = s.strip_suffix('s')
&& let Ok(v) = secs.parse::<u64>()
{
return std::time::Duration::from_secs(v);
}
if let Some(mins) = s.strip_suffix('m')
&& let Ok(v) = mins.parse::<u64>()
&& let Some(seconds) = v.checked_mul(60)
{
return std::time::Duration::from_secs(seconds);
}
if let Some(hours) = s.strip_suffix('h')
&& let Ok(v) = hours.parse::<u64>()
&& let Some(seconds) = v.checked_mul(3600)
{
return std::time::Duration::from_secs(seconds);
}
// Fallback: try parsing as raw seconds
if let Ok(v) = s.parse::<u64>() {
return std::time::Duration::from_secs(v);
}
std::time::Duration::from_secs(60)
}
/// Parse minFreeSpace / minFreeSpacePercent like Go's
/// `util.MustParseMinFreeSpace()`: `--minFreeSpace` wins when set, and any
/// invalid comma-separated entry fails the whole list.
fn parse_min_free_spaces(
min_free_space: &str,
min_free_space_percent: &str,
) -> Result<Vec<MinFreeSpace>, String> {
/// Parse minFreeSpace / minFreeSpacePercent into MinFreeSpace values.
/// Mirrors Go's `util.MustParseMinFreeSpace()`.
fn parse_min_free_spaces(min_free_space: &str, min_free_space_percent: &str) -> Vec<MinFreeSpace> {
// If --minFreeSpace is provided, use it (takes precedence).
let source = if !min_free_space.is_empty() {
min_free_space
} else {
min_free_space_percent
};
source.split(',').map(parse_min_free_space).collect()
source
.split(',')
.map(|s| {
let s = s.trim();
// Try parsing as a percentage (value <= 100)
if let Ok(v) = s.parse::<f64>() {
if v <= 100.0 {
return MinFreeSpace::Percent(v);
}
// Treat as bytes if > 100
return MinFreeSpace::Bytes(v as u64);
}
// Try parsing human-readable bytes: e.g. "10GiB", "500MiB", "1TiB"
let s_upper = s.to_uppercase();
if let Some(rest) = s_upper.strip_suffix("TIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0 * 1024.0) as u64);
}
if let Some(rest) = s_upper.strip_suffix("GIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0) as u64);
}
if let Some(rest) = s_upper.strip_suffix("MIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0) as u64);
}
if let Some(rest) = s_upper.strip_suffix("KIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0) as u64);
}
if let Some(rest) = s_upper.strip_suffix("TB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1_000_000_000_000.0) as u64);
}
if let Some(rest) = s_upper.strip_suffix("GB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1_000_000_000.0) as u64);
}
if let Some(rest) = s_upper.strip_suffix("MB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1_000_000.0) as u64);
}
// Default: 1%
MinFreeSpace::Percent(1.0)
})
.collect()
}
/// Parse comma-separated tag groups like "fast:ssd,archive" into per-folder tag vectors.
@@ -782,9 +657,7 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
// Parse min free spaces
let mut min_free_spaces =
parse_min_free_spaces(&cli.min_free_space, &cli.min_free_space_percent).unwrap_or_else(
|e| panic!("The value specified in -minFreeSpace not a valid value: {e}"),
);
parse_min_free_spaces(&cli.min_free_space, &cli.min_free_space_percent);
if min_free_spaces.len() == 1 && folder_count > 1 {
let v = min_free_spaces[0].clone();
min_free_spaces.resize(folder_count, v);
@@ -899,6 +772,10 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
.collect();
white_list.extend(sec.guard_white_list.iter().cloned());
// Parse durations
let inflight_upload_data_timeout = parse_duration(&cli.inflight_upload_data_timeout);
let inflight_download_data_timeout = parse_duration(&cli.inflight_download_data_timeout);
VolumeServerConfig {
port: cli.port,
grpc_port,
@@ -930,8 +807,8 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
file_size_limit_bytes: cli.file_size_limit_mb as i64 * 1024 * 1024,
concurrent_upload_limit: cli.concurrent_upload_limit_mb as i64 * 1024 * 1024,
concurrent_download_limit: cli.concurrent_download_limit_mb as i64 * 1024 * 1024,
inflight_upload_data_timeout: cli.inflight_upload_data_timeout,
inflight_download_data_timeout: cli.inflight_download_data_timeout,
inflight_upload_data_timeout,
inflight_download_data_timeout,
has_slow_read: cli.has_slow_read,
read_buffer_size_mb: cli.read_buffer_size_mb,
ldb_timeout: cli.ldb_timeout,
@@ -1383,224 +1260,25 @@ mod tests {
}
#[test]
fn test_parse_go_duration_accepts_go_grammar() {
use std::time::Duration;
let cases = [
("60s", Duration::from_secs(60)),
("5m", Duration::from_secs(300)),
("1h", Duration::from_secs(3600)),
("1.5h", Duration::from_secs(5400)),
("2h45m", Duration::from_secs(2 * 3600 + 45 * 60)),
("300ms", Duration::from_millis(300)),
("1.5s", Duration::from_millis(1500)),
("250us", Duration::from_micros(250)),
("250\u{00b5}s", Duration::from_micros(250)),
("250\u{03bc}s", Duration::from_micros(250)),
("7ns", Duration::from_nanos(7)),
(".5s", Duration::from_millis(500)),
("5.s", Duration::from_secs(5)),
("+10s", Duration::from_secs(10)),
("0", Duration::ZERO),
("0s", Duration::ZERO),
// Go: fraction digits past the 19th are ignored, not an error.
("1.00000000000000000001s", Duration::from_secs(1)),
];
for (input, expected) in cases {
assert_eq!(parse_go_duration(input), Ok(expected), "input={:?}", input);
}
}
#[test]
fn test_parse_go_duration_rejects_what_go_rejects() {
// Each of these makes Go's flag.Duration fail flag parsing; the old
// Rust parser turned them all into the 60 s default.
let rejected = [
"30", // bare number other than 0
"30sec", // unknown unit
"abc", // no number at all
"", // empty
"1h ", // trailing space is not a unit
"s", // unit without number
".s", // dot without digits
"1.5", // fraction without unit
"1h-30m", // sign is only allowed at the front
"-", // sign alone
"5124095576030432h", // overflows int64 nanoseconds
"307445734561825861m", // overflows int64 nanoseconds
"9223372036854775808ns", // 1<<63, one past int64
];
for input in rejected {
let result = parse_go_duration(input);
assert!(result.is_err(), "input={:?} parsed as {:?}", input, result);
let message = result.unwrap_err();
assert!(
message.contains(&format!("{:?}", input)) || input.is_empty(),
"error for {:?} does not name the value: {}",
input,
message
);
}
// Largest value Go accepts: 1<<63 - 1 nanoseconds.
fn test_parse_duration() {
assert_eq!(parse_duration("60s"), std::time::Duration::from_secs(60));
assert_eq!(parse_duration("5m"), std::time::Duration::from_secs(300));
assert_eq!(parse_duration("1h"), std::time::Duration::from_secs(3600));
assert_eq!(parse_duration("30"), std::time::Duration::from_secs(30));
assert_eq!(parse_duration(""), std::time::Duration::from_secs(60));
assert_eq!(
parse_go_duration("9223372036854775807ns"),
Ok(std::time::Duration::from_nanos(i64::MAX as u64))
);
// Go accepts negatives; std::time::Duration cannot, so it errors.
let negative = parse_go_duration("-1s").unwrap_err();
assert!(negative.contains("negative"), "{}", negative);
}
/// Clap refuses the flag so the process exits with a usage error, exactly
/// where Go's flag package does.
#[test]
fn test_cli_rejects_invalid_duration_flags() {
for value in ["30sec", "abc", "30"] {
let result = Cli::try_parse_from(["bin", "--inflightUploadDataTimeout", value]);
assert!(
result.is_err(),
"--inflightUploadDataTimeout={} was accepted as {:?}",
value,
result.map(|cli| cli.inflight_upload_data_timeout)
);
let err = Cli::try_parse_from(["bin", "--inflightDownloadDataTimeout", value])
.err()
.unwrap_or_else(|| panic!("--inflightDownloadDataTimeout={} was accepted", value));
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
assert!(err.to_string().contains(value), "{}", err);
}
let cli = Cli::parse_from(["bin", "--inflightUploadDataTimeout", "1.5h"]);
assert_eq!(
cli.inflight_upload_data_timeout,
std::time::Duration::from_secs(5400)
);
// The default still parses through the same grammar.
let cli = Cli::parse_from(["bin"]);
assert_eq!(
cli.inflight_upload_data_timeout,
parse_duration("307445734561825861m"),
std::time::Duration::from_secs(60)
);
assert_eq!(
cli.inflight_download_data_timeout,
parse_duration("5124095576030432h"),
std::time::Duration::from_secs(60)
);
}
#[test]
fn test_parse_bytes_matches_go_parse_bytes() {
// Expected values are what Go's util.ParseBytes returns (weed/util/bytes_test.go).
let cases: [(&str, u64); 14] = [
("42", 42),
("42MB", 42_000_000),
("42 MB", 42_000_000),
("42 mib", 44_040_192),
("42M", 42_000_000),
("100Ki", 100 * 1024),
("10GiB", 10 * 1024 * 1024 * 1024),
("10gib", 10 * 1024 * 1024 * 1024),
("10GB", 10_000_000_000),
("1.5TB", 1_500_000_000_000),
// Go's ParseBytes accepts thousands commas; the -minFreeSpace flag
// never delivers one (see test_min_free_space_flag_commas_are_list_separators).
("1,024MB", 1_024_000_000),
("1,024", 1024),
("1PiB", 1 << 50),
("1eb", 1_000_000_000_000_000_000),
];
for (input, expected) in cases {
assert_eq!(parse_bytes(input), Ok(expected), "input={:?}", input);
}
for input in [
"10GiBx",
"GiB",
"",
"-5GB",
"1.2.3GB",
"10 Gi B",
"99999999999999999999EB",
] {
assert!(
parse_bytes(input).is_err(),
"input={:?} was accepted",
input
);
}
}
fn expect_percent(result: Result<MinFreeSpace, String>, expected: f64, input: &str) {
match result {
Ok(MinFreeSpace::Percent(v)) => {
assert!(
(v - expected).abs() < f64::EPSILON,
"input={:?}: {}",
input,
v
)
}
other => panic!(
"input={:?}: expected Percent({}), got {:?}",
input, expected, other
),
}
}
fn expect_bytes(result: Result<MinFreeSpace, String>, expected: u64, input: &str) {
match result {
Ok(MinFreeSpace::Bytes(v)) => assert_eq!(v, expected, "input={:?}", input),
other => panic!(
"input={:?}: expected Bytes({}), got {:?}",
input, expected, other
),
}
}
#[test]
fn test_parse_min_free_space_matches_go() {
// Fixtures from weed/util/minfreespace_test.go plus the byte-unit table.
expect_percent(parse_min_free_space("42"), 42.0, "42");
expect_percent(parse_min_free_space(" 42 "), 42.0, " 42 ");
expect_percent(parse_min_free_space("50"), 50.0, "50");
expect_percent(parse_min_free_space("100"), 100.0, "100");
expect_percent(parse_min_free_space("0"), 0.0, "0");
expect_percent(parse_min_free_space("2.5"), 2.5, "2.5");
expect_bytes(parse_min_free_space("100Ki"), 100 * 1024, "100Ki");
expect_bytes(parse_min_free_space("100GiB"), 100 << 30, "100GiB");
expect_bytes(parse_min_free_space("42M"), 42_000_000, "42M");
expect_bytes(parse_min_free_space(" 42M "), 42_000_000, " 42M ");
expect_bytes(parse_min_free_space("10GiB"), 10 << 30, "10GiB");
expect_bytes(parse_min_free_space("10gib"), 10 << 30, "10gib");
expect_bytes(parse_min_free_space("1.5TB"), 1_500_000_000_000, "1.5TB");
// Comma inputs never arrive whole from the flag; see
// test_min_free_space_flag_commas_are_list_separators.
expect_bytes(parse_min_free_space("1,024MB"), 1_024_000_000, "1,024MB");
expect_bytes(parse_min_free_space("1,000"), 1000, "1,000");
// Go: percent outside 0..=100, or a byte size of at most 100 bytes.
for input in [
"150", "101", "-1", "1e3", "inf", "nan", "100B", "50B", "0B", "10GiBx", "", "abc",
"10 Gi B",
] {
let result = parse_min_free_space(input);
assert!(
result.is_err(),
"input={:?} was accepted as {:?}",
input,
result
);
assert!(
result
.as_ref()
.unwrap_err()
.contains(&format!("{:?}", input.trim())),
"error for {:?} does not name the value: {:?}",
input,
result
);
}
}
#[test]
fn test_parse_min_free_spaces_percent() {
let result = parse_min_free_spaces("", "1").unwrap();
let result = parse_min_free_spaces("", "1");
assert_eq!(result.len(), 1);
match &result[0] {
MinFreeSpace::Percent(v) => assert!((v - 1.0).abs() < f64::EPSILON),
@@ -1610,7 +1288,7 @@ mod tests {
#[test]
fn test_parse_min_free_spaces_bytes() {
let result = parse_min_free_spaces("10GiB", "").unwrap();
let result = parse_min_free_spaces("10GiB", "");
assert_eq!(result.len(), 1);
match &result[0] {
MinFreeSpace::Bytes(v) => assert_eq!(*v, 10 * 1024 * 1024 * 1024),
@@ -1618,87 +1296,6 @@ mod tests {
}
}
#[test]
fn test_parse_min_free_spaces_list_and_precedence() {
// --minFreeSpace wins over --minFreeSpacePercent, as in Go's EmptyTo().
let result = parse_min_free_spaces("5, 10GiB", "1").unwrap();
assert_eq!(result.len(), 2);
assert!(matches!(result[0], MinFreeSpace::Percent(v) if v == 5.0));
assert!(matches!(result[1], MinFreeSpace::Bytes(v) if v == 10 << 30));
// One bad entry fails the whole list and names that entry.
let err = parse_min_free_spaces("5,10GiBx", "1").unwrap_err();
assert!(err.contains("\"10GiBx\""), "{}", err);
// Both flags empty: Go fatals (ParseMinFreeSpace("") fails) rather than
// assuming 1%.
assert!(parse_min_free_spaces("", "").is_err());
}
/// A comma in `-minFreeSpace` separates per-directory entries, as in Go's
/// `MustParseMinFreeSpace`: `1,024MB` is `1` (percent) and `024MB`, and
/// one `-dir` fails the count check.
#[test]
fn test_min_free_space_flag_commas_are_list_separators() {
let result = parse_min_free_spaces("1,024MB", "").unwrap();
assert_eq!(result.len(), 2, "{result:?}");
assert!(
matches!(result[0], MinFreeSpace::Percent(v) if v == 1.0),
"{result:?}"
);
assert!(
matches!(result[1], MinFreeSpace::Bytes(v) if v == 24_000_000),
"{result:?}"
);
let _guard = process_state_lock();
let cli = Cli::parse_from(["bin", "--dir", "/tmp/a", "--minFreeSpace", "1,024MB"]);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve_config_with_env(cli, &|_| None).min_free_spaces
}));
assert!(
outcome.is_err(),
"one -dir with two minFreeSpace entries must abort"
);
}
/// Negative control at the startup layer: resolving the config with an
/// invalid --minFreeSpace must abort, as Go's MustParseMinFreeSpace does.
#[test]
fn test_resolve_config_rejects_invalid_min_free_space() {
// The security-config search reads the working directory.
let _guard = process_state_lock();
for (flag, value) in [
("--minFreeSpace", "10GiBx"),
("--minFreeSpace", "150"),
("--minFreeSpace", "50B"),
("--minFreeSpacePercent", "150"),
("--minFreeSpacePercent", "abc"),
] {
let cli = Cli::parse_from(["bin", flag, value]);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve_config_with_env(cli, &|_| None).min_free_spaces
}));
let payload = match outcome {
Ok(spaces) => panic!("{}={} was accepted as {:?}", flag, value, spaces),
Err(payload) => payload,
};
let message = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
assert!(
message.contains("-minFreeSpace") && message.contains(value),
"{}={}: unexpected panic message {:?}",
flag,
value,
message
);
}
let cfg =
resolve_config_with_env(Cli::parse_from(["bin", "--minFreeSpace", "100"]), &|_| None);
assert!(matches!(cfg.min_free_spaces[0], MinFreeSpace::Percent(v) if v == 100.0));
}
#[test]
fn test_parse_volume_tags_single() {
let tags = parse_volume_tags("fast:ssd", 3);
+21 -219
View File
@@ -4,7 +4,6 @@
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::error::{DisplayErrorContext, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use super::{RemoteEntry, RemoteStorageClient, RemoteStorageError};
@@ -26,23 +25,6 @@ impl S3RemoteStorageClient {
endpoint: &str,
force_path_style: bool,
) -> Self {
let client = Client::from_conf(
Self::config_builder(access_key, secret_key, region, endpoint, force_path_style)
.build(),
);
S3RemoteStorageClient { client, conf }
}
/// Build the SDK config for the given credentials and endpoint. Split out so
/// tests can attach a canned HTTP client before building the [`Client`].
fn config_builder(
access_key: &str,
secret_key: &str,
region: &str,
endpoint: &str,
force_path_style: bool,
) -> aws_sdk_s3::config::Builder {
let region = if region.is_empty() {
"us-east-1"
} else {
@@ -67,7 +49,9 @@ impl S3RemoteStorageClient {
s3_config = s3_config.endpoint_url(endpoint);
}
s3_config
let client = Client::from_conf(s3_config.build());
S3RemoteStorageClient { client, conf }
}
}
@@ -91,14 +75,13 @@ impl RemoteStorageClient for S3RemoteStorageClient {
req = req.range(format!("bytes={}-", offset));
}
let resp = req.send().await.map_err(|e| match e {
// Go compares `aerr.Code()` to NoSuchKey on GET
// (s3_storage_client.go:436): a bare 404 maps to "NotFound"
// and stays a generic error, as it does here.
SdkError::ServiceError(ref se) if se.err().is_no_such_key() => {
let resp = req.send().await.map_err(|e| {
let msg = format!("{}", e);
if msg.contains("NoSuchKey") || msg.contains("404") {
RemoteStorageError::ObjectNotFound(format!("{}/{}", loc.bucket, key))
} else {
RemoteStorageError::Other(format!("s3 get object: {}", e))
}
e => RemoteStorageError::Other(format!("s3 get object: {}", DisplayErrorContext(&e))),
})?;
let data = resp
@@ -125,9 +108,7 @@ impl RemoteStorageClient for S3RemoteStorageClient {
.body(ByteStream::from(data.to_vec()))
.send()
.await
.map_err(|e| {
RemoteStorageError::Other(format!("s3 put object: {}", DisplayErrorContext(&e)))
})?;
.map_err(|e| RemoteStorageError::Other(format!("s3 put object: {}", e)))?;
Ok(RemoteEntry {
size: data.len() as i64,
@@ -153,18 +134,13 @@ impl RemoteStorageClient for S3RemoteStorageClient {
.key(key)
.send()
.await
.map_err(|e| match e {
// Go checks only the raw HTTP status on HEAD
// (s3_storage_client.go:373): a HEAD response carries no
// error body, so a 404 is not-found whatever code the SDK
// assigns, and a non-404 is not.
SdkError::ServiceError(ref se) if se.raw().status().as_u16() == 404 => {
.map_err(|e| {
let msg = format!("{}", e);
if msg.contains("404") || msg.contains("NotFound") {
RemoteStorageError::ObjectNotFound(format!("{}/{}", loc.bucket, key))
} else {
RemoteStorageError::Other(format!("s3 head object: {}", e))
}
e => RemoteStorageError::Other(format!(
"s3 head object: {}",
DisplayErrorContext(&e)
)),
})?;
Ok(RemoteEntry {
@@ -184,17 +160,18 @@ impl RemoteStorageClient for S3RemoteStorageClient {
.key(key)
.send()
.await
.map_err(|e| {
RemoteStorageError::Other(format!("s3 delete object: {}", DisplayErrorContext(&e)))
})?;
.map_err(|e| RemoteStorageError::Other(format!("s3 delete object: {}", e)))?;
Ok(())
}
async fn list_buckets(&self) -> Result<Vec<String>, RemoteStorageError> {
let resp = self.client.list_buckets().send().await.map_err(|e| {
RemoteStorageError::Other(format!("s3 list buckets: {}", DisplayErrorContext(&e)))
})?;
let resp = self
.client
.list_buckets()
.send()
.await
.map_err(|e| RemoteStorageError::Other(format!("s3 list buckets: {}", e)))?;
Ok(resp
.buckets()
@@ -207,178 +184,3 @@ impl RemoteStorageClient for S3RemoteStorageClient {
&self.conf
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use aws_sdk_s3::config::http::{HttpRequest, HttpResponse};
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::config::{HttpClient, RuntimeComponents};
use aws_sdk_s3::primitives::SdkBody;
use aws_smithy_runtime_api::client::http::{
HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector,
};
use aws_smithy_runtime_api::http::StatusCode;
/// An SDK HTTP client that answers every request with one canned response,
/// so the error-mapping paths can be exercised without a network or a
/// running S3 server.
#[derive(Debug, Clone)]
pub(crate) struct CannedResponse {
pub(crate) status: u16,
pub(crate) body: &'static str,
}
impl HttpConnector for CannedResponse {
fn call(&self, _request: HttpRequest) -> HttpConnectorFuture {
let status = StatusCode::try_from(self.status).expect("valid HTTP status");
HttpConnectorFuture::ready(Ok(HttpResponse::new(status, SdkBody::from(self.body))))
}
}
impl HttpClient for CannedResponse {
fn http_connector(
&self,
_settings: &HttpConnectorSettings,
_components: &RuntimeComponents,
) -> SharedHttpConnector {
SharedHttpConnector::new(self.clone())
}
}
fn client_with(status: u16, body: &'static str) -> S3RemoteStorageClient {
let config = S3RemoteStorageClient::config_builder(
"AKIATEST",
"secret",
"us-east-1",
"http://127.0.0.1:1",
true,
)
.http_client(CannedResponse { status, body })
.retry_config(RetryConfig::disabled())
.build();
S3RemoteStorageClient {
client: Client::from_conf(config),
conf: RemoteConf::default(),
}
}
fn location() -> RemoteStorageLocation {
RemoteStorageLocation {
name: "remote".to_string(),
bucket: "bucket".to_string(),
path: "/dir/missing".to_string(),
..Default::default()
}
}
pub(crate) const NO_SUCH_KEY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message><Key>dir/missing</Key></Error>"#;
const NOT_FOUND_BODY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>NotFound</Code><Message>Not Found</Message></Error>"#;
const ACCESS_DENIED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"#;
#[tokio::test]
async fn get_no_such_key_is_object_not_found() {
let err = client_with(404, NO_SUCH_KEY)
.read_file(&location(), 0, 0)
.await
.unwrap_err();
assert!(
matches!(&err, RemoteStorageError::ObjectNotFound(path) if path == "bucket/dir/missing"),
"expected ObjectNotFound, got {err:?}"
);
}
#[tokio::test]
async fn get_bare_404_is_not_object_not_found() {
// Go compares codes, not statuses, on GET: a body-less 404 stays generic.
let err = client_with(404, "")
.read_file(&location(), 0, 0)
.await
.unwrap_err();
assert!(
matches!(err, RemoteStorageError::Other(_)),
"expected Other, got {err:?}"
);
}
#[tokio::test]
async fn head_404_is_object_not_found() {
let err = client_with(404, "")
.stat_file(&location())
.await
.unwrap_err();
assert!(
matches!(&err, RemoteStorageError::ObjectNotFound(path) if path == "bucket/dir/missing"),
"expected ObjectNotFound, got {err:?}"
);
}
#[tokio::test]
async fn head_404_with_foreign_error_body_is_object_not_found() {
// The raw status check makes a 404 not-found whatever body it carries.
let err = client_with(404, NO_SUCH_KEY)
.stat_file(&location())
.await
.unwrap_err();
assert!(
matches!(err, RemoteStorageError::ObjectNotFound(_)),
"expected ObjectNotFound, got {err:?}"
);
}
#[tokio::test]
async fn head_not_found_code_on_a_non_404_status_is_not_object_not_found() {
// A NotFound body on a non-404 status stays an error, as in Go.
let err = client_with(400, NOT_FOUND_BODY)
.stat_file(&location())
.await
.unwrap_err();
assert!(
matches!(&err, RemoteStorageError::Other(msg) if msg.contains("NotFound")),
"expected Other naming the code, got {err:?}"
);
}
#[tokio::test]
async fn get_access_denied_keeps_service_error_code() {
let err = client_with(403, ACCESS_DENIED)
.read_file(&location(), 0, 0)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, RemoteStorageError::Other(_)),
"expected Other, got {err:?}"
);
assert!(
msg.contains("AccessDenied"),
"message should carry the S3 error code, got: {msg}"
);
assert!(
!msg.ends_with("service error"),
"message should not be the bare SdkError Display, got: {msg}"
);
}
#[tokio::test]
async fn head_access_denied_keeps_service_error_code() {
let err = client_with(403, ACCESS_DENIED)
.stat_file(&location())
.await
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, RemoteStorageError::Other(_)),
"expected Other, got {err:?}"
);
assert!(
msg.contains("AccessDenied"),
"message should carry the S3 error code, got: {msg}"
);
}
}
+56 -407
View File
@@ -8,11 +8,7 @@ use std::future::Future;
use std::sync::{Arc, OnceLock, RwLock};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::http::HttpResponse;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::error::{DisplayErrorContext, SdkError};
use aws_sdk_s3::operation::get_object::GetObjectError;
use aws_sdk_s3::operation::head_object::HeadObjectError;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::sync::Semaphore;
@@ -20,53 +16,6 @@ use tokio::sync::Semaphore;
/// Concurrency limit for multipart upload/download (matches Go's s3manager).
const CONCURRENCY: usize = 5;
/// A tier transfer failure. The variant is what callers match on; the
/// message is the operator-facing text.
#[derive(Debug, thiserror::Error)]
pub enum TierError {
/// The remote object does not exist.
#[error("{0}")]
NotFound(String),
/// An S3 request or a local file operation failed.
#[error("{0}")]
Io(String),
/// The tier I/O runtime could not be built or dropped the task.
#[error("{0}")]
RuntimeUnavailable(String),
/// The progress callback asked to stop.
#[error("{0}")]
Aborted(String),
}
// Not-found rules as in remote_storage/s3.rs: HEAD by the raw 404 status,
// GET by the NoSuchKey code only.
fn head_object_error(key: &str, e: SdkError<HeadObjectError, HttpResponse>) -> TierError {
let message = format!("failed to head object {}: {}", key, DisplayErrorContext(&e));
match e {
SdkError::ServiceError(ref se) if se.raw().status().as_u16() == 404 => {
TierError::NotFound(message)
}
_ => TierError::Io(message),
}
}
fn get_object_error(
key: &str,
range: &str,
e: SdkError<GetObjectError, HttpResponse>,
) -> TierError {
let message = format!(
"failed to get object {} range {}: {}",
key,
range,
DisplayErrorContext(&e)
);
match e {
SdkError::ServiceError(ref se) if se.err().is_no_such_key() => TierError::NotFound(message),
_ => TierError::Io(message),
}
}
/// Configuration for an S3 tier backend.
#[derive(Debug, Clone)]
pub struct S3TierConfig {
@@ -140,7 +89,7 @@ impl S3TierBackend {
&self,
file_path: &str,
progress_fn: F,
) -> Result<(String, u64), TierError>
) -> Result<(String, u64), String>
where
F: FnMut(i64, f32) -> Result<(), String> + Send + Sync + 'static,
{
@@ -148,7 +97,7 @@ impl S3TierBackend {
let metadata = tokio::fs::metadata(file_path)
.await
.map_err(|e| TierError::Io(format!("failed to stat file {}: {}", file_path, e)))?;
.map_err(|e| format!("failed to stat file {}: {}", file_path, e))?;
let file_size = metadata.len();
// Calculate part size: start at 64MB, scale up for very large files (matches Go)
@@ -170,16 +119,11 @@ impl S3TierBackend {
)
.send()
.await
.map_err(|e| {
TierError::Io(format!(
"failed to create multipart upload: {}",
DisplayErrorContext(&e)
))
})?;
.map_err(|e| format!("failed to create multipart upload: {}", e))?;
let upload_id = create_resp
.upload_id()
.ok_or_else(|| TierError::Io("no upload_id in multipart upload response".to_string()))?
.ok_or_else(|| "no upload_id in multipart upload response".to_string())?
.to_string();
// Build list of (part_number, offset, size) for all parts
@@ -215,21 +159,19 @@ impl S3TierBackend {
let _permit = sem
.acquire()
.await
.map_err(|e| TierError::Io(format!("semaphore error: {}", e)))?;
.map_err(|e| format!("semaphore error: {}", e))?;
// Read this part's data from the file at the correct offset
let mut file = tokio::fs::File::open(&fp)
.await
.map_err(|e| TierError::Io(format!("failed to open file {}: {}", fp, e)))?;
.map_err(|e| format!("failed to open file {}: {}", fp, e))?;
file.seek(std::io::SeekFrom::Start(off))
.await
.map_err(|e| {
TierError::Io(format!("failed to seek to offset {}: {}", off, e))
})?;
.map_err(|e| format!("failed to seek to offset {}: {}", off, e))?;
let mut buf = vec![0u8; size];
file.read_exact(&mut buf).await.map_err(|e| {
TierError::Io(format!("failed to read file at offset {}: {}", off, e))
})?;
file.read_exact(&mut buf)
.await
.map_err(|e| format!("failed to read file at offset {}: {}", off, e))?;
let upload_part_resp = client
.upload_part()
@@ -241,12 +183,7 @@ impl S3TierBackend {
.send()
.await
.map_err(|e| {
TierError::Io(format!(
"failed to upload part {} at offset {}: {}",
pn,
off,
DisplayErrorContext(&e)
))
format!("failed to upload part {} at offset {}: {}", pn, off, e)
})?;
let e_tag = upload_part_resp.e_tag().unwrap_or_default().to_string();
@@ -265,9 +202,9 @@ impl S3TierBackend {
};
(guard.1)(uploaded as i64, pct)
};
progress_result.map_err(TierError::Aborted)?;
progress_result?;
Ok::<_, TierError>(
Ok::<_, String>(
CompletedPart::builder()
.e_tag(e_tag)
.part_number(pn)
@@ -282,7 +219,7 @@ impl S3TierBackend {
for handle in handles {
let part = handle
.await
.map_err(|e| TierError::Io(format!("upload task panicked: {}", e)))??;
.map_err(|e| format!("upload task panicked: {}", e))??;
completed_parts.push(part);
}
@@ -299,14 +236,9 @@ impl S3TierBackend {
.multipart_upload(completed_upload)
.send()
.await
.map_err(|e| {
TierError::Io(format!(
"failed to complete multipart upload: {}",
DisplayErrorContext(&e)
))
})?;
.map_err(|e| format!("failed to complete multipart upload: {}", e))?;
Ok::<(), TierError>(())
Ok::<(), String>(())
}
.await;
@@ -349,7 +281,7 @@ impl S3TierBackend {
dest_path: &str,
key: &str,
progress_fn: F,
) -> Result<u64, TierError>
) -> Result<u64, String>
where
F: FnMut(i64, f32) -> Result<(), String> + Send + Sync + 'static,
{
@@ -361,7 +293,7 @@ impl S3TierBackend {
.key(key)
.send()
.await
.map_err(|e| head_object_error(key, e))?;
.map_err(|e| format!("failed to head object {}: {}", key, e))?;
let file_size = head_resp.content_length().unwrap_or(0) as u64;
@@ -373,12 +305,10 @@ impl S3TierBackend {
.truncate(true)
.open(dest_path)
.await
.map_err(|e| {
TierError::Io(format!("failed to open dest file {}: {}", dest_path, e))
})?;
.map_err(|e| format!("failed to open dest file {}: {}", dest_path, e))?;
file.set_len(file_size)
.await
.map_err(|e| TierError::Io(format!("failed to set file length: {}", e)))?;
.map_err(|e| format!("failed to set file length: {}", e))?;
}
let part_size: u64 = 64 * 1024 * 1024;
@@ -414,7 +344,7 @@ impl S3TierBackend {
let _permit = sem
.acquire()
.await
.map_err(|e| TierError::Io(format!("semaphore error: {}", e)))?;
.map_err(|e| format!("semaphore error: {}", e))?;
let end = off + size - 1;
let range = format!("bytes={}-{}", off, end);
@@ -426,13 +356,13 @@ impl S3TierBackend {
.range(&range)
.send()
.await
.map_err(|e| get_object_error(&key, &range, e))?;
.map_err(|e| format!("failed to get object {} range {}: {}", key, range, e))?;
let body = get_resp
.body
.collect()
.await
.map_err(|e| TierError::Io(format!("failed to read body: {}", e)))?;
.map_err(|e| format!("failed to read body: {}", e))?;
let bytes = body.into_bytes();
// Write at the correct offset (like Go's WriteAt)
@@ -440,17 +370,13 @@ impl S3TierBackend {
.write(true)
.open(&dp)
.await
.map_err(|e| {
TierError::Io(format!("failed to open dest file {}: {}", dp, e))
})?;
.map_err(|e| format!("failed to open dest file {}: {}", dp, e))?;
file.seek(std::io::SeekFrom::Start(off))
.await
.map_err(|e| {
TierError::Io(format!("failed to seek to offset {}: {}", off, e))
})?;
.map_err(|e| format!("failed to seek to offset {}: {}", off, e))?;
file.write_all(&bytes)
.await
.map_err(|e| TierError::Io(format!("failed to write to {}: {}", dp, e)))?;
.map_err(|e| format!("failed to write to {}: {}", dp, e))?;
// Report progress. The lock is released before the result is
// propagated so an aborting callback cannot poison the mutex
@@ -466,9 +392,9 @@ impl S3TierBackend {
};
(guard.1)(downloaded as i64, pct)
};
progress_result.map_err(TierError::Aborted)?;
progress_result?;
Ok::<_, TierError>(())
Ok::<_, String>(())
}));
}
@@ -476,7 +402,7 @@ impl S3TierBackend {
for handle in handles {
handle
.await
.map_err(|e| TierError::Io(format!("download task panicked: {}", e)))??;
.map_err(|e| format!("download task panicked: {}", e))??;
}
// fsync the file so its content is durable before the caller trims the .vif
@@ -485,21 +411,16 @@ impl S3TierBackend {
.write(true)
.open(dest_path)
.await
.map_err(|e| TierError::Io(format!("failed to open {} for fsync: {}", dest_path, e)))?;
.map_err(|e| format!("failed to open {} for fsync: {}", dest_path, e))?;
synced
.sync_all()
.await
.map_err(|e| TierError::Io(format!("failed to fsync {}: {}", dest_path, e)))?;
.map_err(|e| format!("failed to fsync {}: {}", dest_path, e))?;
Ok(file_size)
}
pub async fn read_range(
&self,
key: &str,
offset: u64,
size: usize,
) -> Result<Vec<u8>, TierError> {
pub async fn read_range(&self, key: &str, offset: u64, size: usize) -> Result<Vec<u8>, String> {
let end = offset + (size as u64).saturating_sub(1);
let range = format!("bytes={}-{}", offset, end);
let resp = self
@@ -510,35 +431,29 @@ impl S3TierBackend {
.range(&range)
.send()
.await
.map_err(|e| get_object_error(key, &range, e))?;
.map_err(|e| format!("failed to get object {} range {}: {}", key, range, e))?;
let body = resp
.body
.collect()
.await
.map_err(|e| TierError::Io(format!("failed to read object {} body: {}", key, e)))?;
.map_err(|e| format!("failed to read object {} body: {}", key, e))?;
Ok(body.into_bytes().to_vec())
}
/// Delete a file from S3.
pub async fn delete_file(&self, key: &str) -> Result<(), TierError> {
pub async fn delete_file(&self, key: &str) -> Result<(), String> {
self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|e| {
TierError::Io(format!(
"failed to delete object {}: {}",
key,
DisplayErrorContext(&e)
))
})?;
.map_err(|e| format!("failed to delete object {}: {}", key, e))?;
Ok(())
}
pub fn delete_file_blocking(&self, key: &str) -> Result<(), TierError> {
pub fn delete_file_blocking(&self, key: &str) -> Result<(), String> {
let client = self.client.clone();
let bucket = self.bucket.clone();
let key = key.to_string();
@@ -549,13 +464,7 @@ impl S3TierBackend {
.key(&key)
.send()
.await
.map_err(|e| {
TierError::Io(format!(
"failed to delete object {}: {}",
key,
DisplayErrorContext(&e)
))
})?;
.map_err(|e| format!("failed to delete object {}: {}", key, e))?;
Ok(())
})
}
@@ -565,7 +474,7 @@ impl S3TierBackend {
key: &str,
offset: u64,
size: usize,
) -> Result<Vec<u8>, TierError> {
) -> Result<Vec<u8>, String> {
let client = self.client.clone();
let bucket = self.bucket.clone();
let key = key.to_string();
@@ -579,12 +488,13 @@ impl S3TierBackend {
.range(&range)
.send()
.await
.map_err(|e| get_object_error(&key, &range, e))?;
.map_err(|e| format!("failed to get object {} range {}: {}", key, range, e))?;
let body =
resp.body.collect().await.map_err(|e| {
TierError::Io(format!("failed to read object {} body: {}", key, e))
})?;
let body = resp
.body
.collect()
.await
.map_err(|e| format!("failed to read object {} body: {}", key, e))?;
Ok(body.into_bytes().to_vec())
})
}
@@ -645,279 +555,18 @@ pub fn global_s3_tier_registry() -> &'static RwLock<S3TierRegistry> {
GLOBAL_S3_TIER_REGISTRY.get_or_init(|| RwLock::new(S3TierRegistry::new()))
}
/// The one process-wide runtime for tiered-S3 I/O issued from synchronous
/// storage code. A per-call runtime tore down the SDK's pooled connections
/// after every 64 KiB chunk, re-dialing TLS per read; a long-lived runtime
/// keeps the pool warm.
///
/// Built on first use. A build failure is returned, not cached or panicked:
/// callers sit inside `Volume::destroy` and needle reads, whose own error
/// paths must run, and a later call may succeed.
static TIER_RUNTIME: std::sync::Mutex<Option<tokio::runtime::Runtime>> =
std::sync::Mutex::new(None);
fn tier_handle() -> Result<tokio::runtime::Handle, TierError> {
let mut slot = TIER_RUNTIME
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if slot.is_none() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("tier-io")
.enable_all()
.build()
.map_err(|e| {
TierError::RuntimeUnavailable(format!(
"failed to build the tier I/O tokio runtime: {}",
e
))
})?;
*slot = Some(runtime);
}
Ok(slot.as_ref().expect("just initialised").handle().clone())
}
/// Run `future` on the tier runtime and block the calling thread until it
/// finishes. The caller may be a worker of *another* tokio runtime, so this
/// waits on a channel rather than `Handle::block_on`, which panics when
/// called from inside any runtime context.
fn block_on_tier_future<F, T>(future: F) -> Result<T, TierError>
fn block_on_tier_future<F, T>(future: F) -> Result<T, String>
where
F: Future<Output = Result<T, TierError>> + Send + 'static,
F: Future<Output = Result<T, String>> + Send + 'static,
T: Send + 'static,
{
let handle = tier_handle()?;
let task = handle.spawn(future);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
// The receiver only goes away if the caller was unwound; nothing to
// report then.
let _ = tx.send(task.await);
});
match rx.recv() {
Ok(Ok(result)) => result,
Ok(Err(join_error)) => Err(describe_join_error(join_error)),
Err(_) => Err(TierError::RuntimeUnavailable(
"tier I/O runtime dropped the task before it finished".to_string(),
)),
}
}
/// Turn a `JoinError` into a message that keeps the panic payload, so an
/// SDK panic surfaces as "boom" rather than a fixed "thread panicked".
fn describe_join_error(join_error: tokio::task::JoinError) -> TierError {
if join_error.is_panic() {
let payload = join_error.into_panic();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
};
TierError::Io(format!("tier I/O task panicked: {}", message))
} else {
TierError::RuntimeUnavailable(format!("tier I/O task failed: {}", join_error))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::remote_storage::s3::tests::{CannedResponse, NO_SUCH_KEY};
use std::collections::HashSet;
use tokio::runtime::Handle;
fn probe() -> Result<(tokio::runtime::Id, Option<String>), TierError> {
block_on_tier_future(async {
Ok((
Handle::current().id(),
std::thread::current().name().map(str::to_string),
))
})
}
#[test]
fn block_on_tier_future_reuses_one_runtime() {
let (first_runtime, first_thread) = probe().expect("first call");
let (second_runtime, second_thread) = probe().expect("second call");
assert_eq!(
first_runtime, second_runtime,
"each call must run on the same long-lived tier runtime"
);
assert_eq!(first_thread.as_deref(), Some("tier-io"));
assert_eq!(second_thread.as_deref(), Some("tier-io"));
let mut runtimes = HashSet::new();
for _ in 0..20 {
let (id, _) = probe().expect("probe");
runtimes.insert(id);
}
assert_eq!(runtimes.len(), 1);
}
#[test]
fn block_on_tier_future_returns_the_value_and_the_error() {
assert_eq!(block_on_tier_future(async { Ok(7u32) }).unwrap(), 7);
let err = block_on_tier_future::<_, u32>(async { Err(TierError::NotFound("nope".into())) })
.unwrap_err();
assert!(
matches!(&err, TierError::NotFound(m) if m == "nope"),
"{err:?}"
);
}
#[test]
fn block_on_tier_future_works_from_a_std_thread() {
let (id, _) = std::thread::spawn(probe)
.join()
.expect("probe thread")
.expect("probe");
assert_eq!(id, tier_handle().expect("tier runtime").id());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn block_on_tier_future_works_from_spawn_blocking() {
let (id, _) = tokio::task::spawn_blocking(probe)
.await
.expect("spawn_blocking")
.expect("probe");
assert_eq!(id, tier_handle().expect("tier runtime").id());
assert_ne!(id, Handle::current().id());
}
// Called straight from another runtime's async context: the case that
// would panic with `Handle::block_on` ("Cannot start a runtime from
// within a runtime").
#[tokio::test]
async fn block_on_tier_future_works_from_a_current_thread_runtime() {
let (id, _) = probe().expect("probe");
assert_eq!(id, tier_handle().expect("tier runtime").id());
assert_ne!(id, Handle::current().id());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn block_on_tier_future_works_from_a_multi_thread_runtime_worker() {
let (id, _) = probe().expect("probe");
assert_eq!(id, tier_handle().expect("tier runtime").id());
assert_ne!(id, Handle::current().id());
}
#[test]
fn block_on_tier_future_reports_the_panic_payload() {
let err = block_on_tier_future::<_, ()>(async {
if std::hint::black_box(true) {
panic!("boom {}", 42);
}
Ok(())
})
.expect_err("a panicking future must be an error");
assert!(matches!(err, TierError::Io(_)), "got: {err:?}");
assert!(err.to_string().contains("boom 42"), "got: {err}");
assert!(err.to_string().contains("panicked"), "got: {err}");
}
#[test]
fn block_on_tier_future_reports_a_str_panic_payload() {
let err = block_on_tier_future::<_, ()>(async {
if std::hint::black_box(true) {
panic!("static boom");
}
Ok(())
})
.expect_err("a panicking future must be an error");
assert!(err.to_string().contains("static boom"), "got: {err}");
}
fn backend_answering(status: u16, body: &'static str) -> S3TierBackend {
let config = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(Credentials::new("AKIATEST", "secret", None, None, "test"))
.endpoint_url("http://127.0.0.1:1")
.force_path_style(true)
.http_client(CannedResponse { status, body })
.retry_config(aws_sdk_s3::config::retry::RetryConfig::disabled())
.build();
S3TierBackend {
client: Client::from_conf(config),
bucket: "bucket".to_string(),
storage_class: "STANDARD".to_string(),
}
}
#[tokio::test]
async fn download_head_404_is_not_found() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("1.dat");
let err = backend_answering(404, "")
.download_file(dest.to_str().unwrap(), "missing", |_, _| Ok(()))
.await
.unwrap_err();
assert!(matches!(err, TierError::NotFound(_)), "{err:?}");
assert!(
err.to_string()
.starts_with("failed to head object missing: "),
"{err}"
);
}
#[tokio::test]
async fn download_head_403_is_io() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("1.dat");
let err = backend_answering(403, "")
.download_file(dest.to_str().unwrap(), "denied", |_, _| Ok(()))
.await
.unwrap_err();
assert!(matches!(err, TierError::Io(_)), "{err:?}");
}
#[tokio::test]
async fn read_range_no_such_key_is_not_found() {
let err = backend_answering(404, NO_SUCH_KEY)
.read_range("missing", 0, 8)
.await
.unwrap_err();
assert!(matches!(err, TierError::NotFound(_)), "{err:?}");
assert!(
err.to_string()
.starts_with("failed to get object missing range bytes=0-7: "),
"{err}"
);
}
#[tokio::test]
async fn read_range_bare_404_is_io() {
// As in Go, GET is not-found by the NoSuchKey code, not the status.
let err = backend_answering(404, "")
.read_range("missing", 0, 8)
.await
.unwrap_err();
assert!(matches!(err, TierError::Io(_)), "{err:?}");
}
#[test]
fn read_range_blocking_no_such_key_is_not_found() {
let err = backend_answering(404, NO_SUCH_KEY)
.read_range_blocking("missing", 0, 8)
.unwrap_err();
assert!(matches!(err, TierError::NotFound(_)), "{err:?}");
}
#[test]
fn backend_name_to_type_id_splits_on_dot() {
assert_eq!(
backend_name_to_type_id("s3"),
("s3".to_string(), "default".to_string())
);
assert_eq!(
backend_name_to_type_id("s3.eu"),
("s3".to_string(), "eu".to_string())
);
assert_eq!(
backend_name_to_type_id("s3.a.b"),
(String::new(), String::new())
);
}
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to build tokio runtime: {}", e))?;
runtime.block_on(future)
})
.join()
.map_err(|_| "tier runtime thread panicked".to_string())?
}
File diff suppressed because it is too large Load Diff
+74 -630
View File
@@ -21,7 +21,6 @@ use super::volume_server::{VolumeServerState, normalize_outgoing_http_url, to_ht
use crate::config::ReadMode;
use crate::metrics;
use crate::pb::volume_server_pb;
use crate::storage::needle::CRC;
use crate::storage::needle::needle::Needle;
use crate::storage::types::*;
@@ -143,78 +142,6 @@ const STREAMING_THRESHOLD: u32 = 1024 * 1024; // 1 MB
/// Default chunk size for streaming reads from the dat file.
const DEFAULT_STREAMING_CHUNK_SIZE: usize = 64 * 1024; // 64 KB
/// Whether a GET/HEAD can be answered from the needle meta and the data file.
#[derive(Clone, Copy)]
struct SourceReadRequest {
is_head: bool,
has_range: bool,
has_image_ops: bool,
bypass_cm: bool,
}
impl SourceReadRequest {
/// The payload can be served as stored.
fn direct(&self, n: &Needle) -> bool {
!n.is_compressed() && !(n.is_chunk_manifest() && !self.bypass_cm) && !self.has_image_ops
}
/// HEAD, a range, or a stream.
fn served_from_source(&self, n: &Needle) -> bool {
self.is_head || (self.direct(n) && (self.has_range || n.data_size > STREAMING_THRESHOLD))
}
/// Meta first only if the needle could be served from source; its data
/// size is below its index size.
fn try_meta_first(&self, size: Size) -> bool {
self.is_head
|| (!self.has_image_ops && (self.has_range || size.0 > STREAMING_THRESHOLD as i32))
}
}
/// Blocking part of a GET/HEAD: the store guard is dropped before any needle
/// I/O, so a slow or tiered read cannot park store writers. `Ok(None)` is a
/// cookie mismatch.
fn read_needle_for_get(
state: &VolumeServerState,
vid: VolumeId,
needle_id: NeedleId,
cookie: Cookie,
read_deleted: bool,
request: SourceReadRequest,
) -> Result<
Option<(Needle, Option<crate::storage::volume::NeedleStreamInfo>)>,
crate::storage::volume::VolumeError,
> {
let mut plan = state
.store
.read()
.unwrap()
.needle_read_plan(vid, needle_id, read_deleted)?;
let blank = || Needle {
id: needle_id,
cookie,
..Needle::default()
};
let mut n = blank();
if request.try_meta_first(plan.size()) {
plan.read_meta(&mut n)?;
if n.cookie != cookie {
return Ok(None);
}
if request.served_from_source(&n) {
let info = plan.into_stream_info(&n);
return Ok(Some((n, Some(info))));
}
// Compressed or a chunk manifest: the payload is needed after all.
n = blank();
}
plan.read_full(&mut n)?;
if n.cookie != cookie {
return Ok(None);
}
Ok(Some((n, None)))
}
/// A body that streams needle data from the dat file in chunks using pread,
/// avoiding loading the entire payload into memory at once.
struct StreamingBody {
@@ -240,10 +167,6 @@ struct StreamingBody {
/// Compaction revision at the time of the initial read; if the volume's revision
/// changes between chunks, the needle may have moved and we must re-lookup its offset.
compaction_revision: u16,
/// Checksum stored in the needle tail.
expected_checksum: u32,
/// Running checksum over the emitted data.
crc: CRC,
}
impl http_body::Body for StreamingBody {
@@ -264,32 +187,7 @@ impl http_body::Body for StreamingBody {
match result {
Ok(Ok(chunk)) => {
let len = chunk.len();
self.crc = self.crc.update(&chunk);
self.pos += len;
// The last frame is held back until the checksum verifies
// (Go parity: readNeedleDataInto). On a mismatch it is
// never emitted, so the response ends short of the declared
// Content-Length and the reader sees a failed transfer.
if self.pos >= self.data_size as usize {
let ok = self.expected_checksum == self.crc.0
|| self.expected_checksum == self.crc.legacy_value();
if !ok {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_CRC])
.inc();
return std::task::Poll::Ready(Some(Err(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"needle data checksum {} expected {} for needle {}",
self.crc.0,
self.expected_checksum,
self.needle_id
),
),
)));
}
}
return std::task::Poll::Ready(Some(Ok(http_body::Frame::data(
chunk,
))));
@@ -313,9 +211,6 @@ impl http_body::Body for StreamingBody {
let relookup_result = {
let store = self.server_state.store.read().unwrap();
if let Some((_, vol)) = store.find_volume(self.volume_id) {
if let Some(e) = vol.unavailable_error() {
return std::task::Poll::Ready(Some(Err(std::io::Error::other(e))));
}
if vol.super_block.compaction_revision != self.compaction_revision {
// Compaction occurred — re-lookup the needle's data offset
Some(vol.re_lookup_needle_data_offset(self.needle_id))
@@ -950,7 +845,7 @@ async fn proxy_request(
if name.as_str().eq_ignore_ascii_case("server") {
continue;
}
response_headers.append(name.clone(), value.clone());
response_headers.insert(name.clone(), value.clone());
}
// Stream the proxy response body instead of buffering it entirely
@@ -1184,7 +1079,11 @@ async fn get_or_head_handler_inner(
// Read needle — branching between regular volume and EC volume paths.
// EC volumes always do a full read (no streaming/meta-only).
let n: Needle;
let mut n = Needle {
id: needle_id,
cookie,
..Needle::default()
};
let read_deleted = query.read_deleted.as_deref() == Some("true");
let has_range = headers.contains_key(header::RANGE);
@@ -1261,49 +1160,24 @@ async fn get_or_head_handler_inner(
can_handle_range_from_source = false;
} else {
// ---- Regular volume read path (with streaming support) ----
bypass_cm = query.cm.as_deref() == Some("false");
track_download = download_guard.is_some();
let request_kind = SourceReadRequest {
is_head: method == Method::HEAD,
has_range,
has_image_ops,
bypass_cm,
};
let read_state = state.clone();
let read = tokio::task::spawn_blocking(move || {
read_needle_for_get(
&read_state,
vid,
needle_id,
cookie,
read_deleted,
request_kind,
)
})
.await;
(n, stream_info) = match read {
Ok(Ok(Some(found))) => found,
// Cookie mismatch
Ok(Ok(None)) => return StatusCode::NOT_FOUND.into_response(),
Ok(Err(
crate::storage::volume::VolumeError::NotFound
| crate::storage::volume::VolumeError::Deleted,
)) => {
// Try meta-only read first for potential streaming
let store = state.store.read().unwrap();
let si_result = store.read_volume_needle_stream_info(vid, &mut n, read_deleted);
stream_info = match si_result {
Ok(info) => Some(info),
Err(crate::storage::volume::VolumeError::StreamingUnsupported) => None,
Err(crate::storage::volume::VolumeError::NotFound) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return StatusCode::NOT_FOUND.into_response();
}
Ok(Err(e)) => {
Err(crate::storage::volume::VolumeError::Deleted) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_INTERNAL])
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("read error: {}", e),
)
.into_response();
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => {
metrics::HANDLER_COUNTER
@@ -1316,9 +1190,19 @@ async fn get_or_head_handler_inner(
.into_response();
}
};
drop(store);
// Stream info is only returned for a reply served from the data file.
let can_direct_source_read = stream_info.is_some() && request_kind.direct(&n);
// Validate cookie
if n.cookie != cookie {
return StatusCode::NOT_FOUND.into_response();
}
bypass_cm = query.cm.as_deref() == Some("false");
track_download = download_guard.is_some();
let can_direct_source_read = stream_info.is_some()
&& !n.is_compressed()
&& !(n.is_chunk_manifest() && !bypass_cm)
&& !has_image_ops;
// Determine if we can stream (large, direct-source eligible, no range)
can_stream = can_direct_source_read
@@ -1329,6 +1213,41 @@ async fn get_or_head_handler_inner(
// Go uses meta-only reads for all HEAD requests, regardless of compression/chunked files.
can_handle_head_from_meta = stream_info.is_some() && method == Method::HEAD;
can_handle_range_from_source = can_direct_source_read && has_range;
// For chunk manifest or any non-streaming path, we need the full data.
// If we can't stream, do a full read now.
if !can_stream && !can_handle_head_from_meta && !can_handle_range_from_source {
// Re-read with full data
let mut n_full = Needle {
id: needle_id,
cookie,
..Needle::default()
};
let store = state.store.read().unwrap();
match store.read_volume_needle_opt(vid, &mut n_full, read_deleted) {
Ok(count) => {
if count < 0 {
return StatusCode::NOT_FOUND.into_response();
}
}
Err(crate::storage::volume::VolumeError::NotFound) => {
return StatusCode::NOT_FOUND.into_response();
}
Err(crate::storage::volume::VolumeError::Deleted) => {
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("read error: {}", e),
)
.into_response();
}
}
drop(store);
// Use the full needle from here (it has the same metadata + data)
n = n_full;
}
}
// Build ETag and Last-Modified BEFORE conditional checks and chunk manifest expansion
@@ -1590,8 +1509,6 @@ async fn get_or_head_handler_inner(
volume_id: info.volume_id,
needle_id: info.needle_id,
compaction_revision: info.compaction_revision,
expected_checksum: info.checksum,
crc: CRC(0),
};
let body = Body::new(streaming);
@@ -1613,19 +1530,12 @@ async fn get_or_head_handler_inner(
&& let (Some(range_header), Some(info)) = (headers.get(header::RANGE), stream_info)
&& let Ok(range_str) = range_header.to_str()
{
let range_str = range_str.to_string();
let tracking = track_download.then(|| state.clone());
return tokio::task::spawn_blocking(move || {
handle_range_request_from_source(&range_str, info, response_headers, tracking)
})
.await
.unwrap_or_else(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("range read error: {}", e),
)
.into_response()
});
return handle_range_request_from_source(
range_str,
info,
response_headers,
track_download.then(|| state.clone()),
);
}
// ---- Buffered path: small files, compressed, images, range requests ----
@@ -2751,15 +2661,8 @@ pub async fn post_handler(
// sees it the same way the primary did.
let fsync = form_value("fsync").as_deref() == Some("true");
// Go computes the checksum while building the needle (CreateNeedleFromRequest).
// The write queue takes the needle away, so the response fields that come
// from it are read here, before the write, rather than from a copy.
n.checksum = crate::storage::needle::crc::CRC::new(&n.data);
let needle_etag = n.etag();
let needle_has_name = n.has_name();
let write_result = if let Some(wq) = state.write_queue.get() {
wq.submit(vid, n, fsync).await
wq.submit(vid, n.clone(), fsync).await
} else {
let mut store = state.store.write().unwrap();
store.write_volume_needle(vid, &mut n, fsync)
@@ -2810,22 +2713,22 @@ pub async fn post_handler(
let resp = match write_result {
Ok((_offset, _size, is_unchanged)) => {
if is_unchanged {
let etag = format!("\"{}\"", needle_etag);
let etag = format!("\"{}\"", n.etag());
(StatusCode::NO_CONTENT, [(header::ETAG, etag)]).into_response()
} else {
// Go only includes contentMd5 when the client provided Content-MD5
let result = UploadResult {
name: if needle_has_name {
name: if n.has_name() {
filename.clone()
} else {
String::new()
},
size: original_data_size, // H3: use original size, not compressed
etag: needle_etag.clone(),
etag: n.etag(),
mime: mime_type.clone(),
content_md5: original_content_md5.clone(),
};
let etag = needle_etag;
let etag = n.etag();
let etag_header = if etag.starts_with('"') {
etag.clone()
} else {
@@ -4670,463 +4573,4 @@ mod tests {
server.abort();
}
/// Repeated target response headers (e.g. Set-Cookie) must all reach the
/// client, as Go's `w.Header().Add` does; only `Server` is dropped.
#[tokio::test]
async fn test_proxy_request_keeps_repeated_response_headers() {
use axum::{Router, routing::get};
let app = Router::new().route(
"/3,01637037d6",
get(|| async {
(
axum::response::AppendHeaders([
(header::SET_COOKIE, "a=1"),
(header::SET_COOKIE, "b=2"),
(header::SERVER, "target"),
]),
"payload",
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let info = ProxyRequestInfo {
original_headers: HeaderMap::new(),
original_query: String::new(),
path: "/3,01637037d6".to_string(),
vid_str: "3".to_string(),
fid_str: "01637037d6".to_string(),
};
let target = VolumeLocation {
url: addr.to_string(),
public_url: String::new(),
grpc_port: 0,
read_only: false,
read_only_can_delete: false,
};
let response = proxy_request(&streaming_test_state(), &info, &target).await;
assert_eq!(response.status(), StatusCode::OK);
let cookies: Vec<_> = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect();
assert_eq!(cookies, vec!["a=1", "b=2"]);
assert!(response.headers().get(header::SERVER).is_none());
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"payload");
server.abort();
}
fn streaming_test_state() -> Arc<VolumeServerState> {
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::store::Store;
test_state_with_store(Store::new(NeedleMapKind::InMemory))
}
fn test_state_with_store(store: crate::storage::store::Store) -> Arc<VolumeServerState> {
use crate::security::{Guard, SigningKey};
use crate::server::volume_server::RuntimeMetricsConfig;
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32};
Arc::new(VolumeServerState {
store: RwLock::new(store),
guard: RwLock::new(Guard::new(
&[],
SigningKey(vec![]),
0,
SigningKey(vec![]),
0,
)),
is_stopping: RwLock::new(false),
maintenance: AtomicBool::new(false),
state_version: AtomicU32::new(0),
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
inflight_upload_data_timeout: std::time::Duration::ZERO,
inflight_download_data_timeout: std::time::Duration::ZERO,
inflight_upload_bytes: AtomicI64::new(0),
inflight_download_bytes: AtomicI64::new(0),
upload_notify: tokio::sync::Notify::new(),
download_notify: tokio::sync::Notify::new(),
data_center: String::new(),
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
is_heartbeating: AtomicBool::new(false),
has_master: false,
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
read_mode: crate::config::ReadMode::Local,
allow_untrusted_remote_endpoints: false,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
outgoing_grpc_tls: None,
metrics_runtime: RwLock::new(RuntimeMetricsConfig::default()),
metrics_notify: tokio::sync::Notify::new(),
fix_jpg_orientation: false,
has_slow_read: false,
read_buffer_size_bytes: 4 * 1024 * 1024,
security_file: String::new(),
cli_white_list: vec![],
state_file_path: String::new(),
})
}
/// Go's readNeedleDataInto holds back the last chunk of a whole-needle read
/// until the checksum verifies; a streamed needle that fails its CRC must
/// likewise end short of the declared length instead of looking complete.
#[tokio::test]
async fn test_streaming_body_checksum_failure_drops_last_chunk() {
use futures::StreamExt;
use std::io::Write;
let data: Vec<u8> = (0..10_000u32).map(|i| (i % 251) as u8).collect();
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&data).unwrap();
let file = tmp.reopen().unwrap();
let control = Arc::new(crate::storage::volume::DataFileAccessControl::default());
let mk_body = |expected_checksum: u32, file: &std::fs::File| StreamingBody {
source: crate::storage::volume::NeedleStreamSource::Local(file.try_clone().unwrap()),
data_offset: 0,
data_size: data.len() as u32,
pos: 0,
chunk_size: 4096,
data_file_access_control: control.clone(),
hold_read_lock_for_stream: true,
_held_read_lease: None,
pending: None,
state: None,
tracked_bytes: 0,
server_state: streaming_test_state(),
volume_id: VolumeId(1),
needle_id: NeedleId(7),
compaction_revision: 0,
expected_checksum,
crc: CRC(0),
};
// Healthy needle: every byte is delivered.
let body = Body::new(mk_body(CRC::new(&data).0, &file));
let mut stream = body.into_data_stream();
let mut got = 0usize;
while let Some(item) = stream.next().await {
got += item.expect("healthy stream errored").len();
}
assert_eq!(got, data.len());
// Corrupted needle: the last chunk fails the checksum and is never
// emitted — the body ends short of the data size.
let body = Body::new(mk_body(CRC::new(&data).0 ^ 0xff, &file));
let mut stream = body.into_data_stream();
let mut got = 0usize;
let mut saw_err = false;
while let Some(item) = stream.next().await {
match item {
Ok(bytes) => got += bytes.len(),
Err(e) => {
assert!(
e.to_string().contains("checksum"),
"unexpected stream error: {e}"
);
saw_err = true;
}
}
}
assert!(saw_err, "corrupted stream must fail");
assert!(got < data.len(), "delivered {got} of {} bytes", data.len());
}
const TEST_COOKIE: u32 = 0x1234_5678;
/// State whose store holds volume 1 in `tmp`.
fn volume_test_state(tmp: &tempfile::TempDir) -> Arc<VolumeServerState> {
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::store::Store;
use crate::storage::volume::VolumeSpec;
let dir = tmp.path().to_str().unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
store
.add_location(
dir,
dir,
10,
DiskType::HardDrive,
crate::config::MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.unwrap();
test_state_with_store(store)
}
/// Write a needle to volume 1 and return its URL path.
fn put_test_needle(state: &VolumeServerState, id: u64, data: &[u8]) -> String {
put_test_needle_with(state, id, data, |_| {})
}
fn put_test_needle_with(
state: &VolumeServerState,
id: u64,
data: &[u8],
prepare: impl FnOnce(&mut Needle),
) -> String {
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(TEST_COOKIE),
data: data.to_vec(),
data_size: data.len() as u32,
..Needle::default()
};
prepare(&mut n);
state
.store
.write()
.unwrap()
.write_volume_needle(VolumeId(1), &mut n, false)
.unwrap();
format!("/1,{:x}{:08x}", id, TEST_COOKIE)
}
async fn send_read(
state: &Arc<VolumeServerState>,
method: Method,
path: &str,
range: Option<&str>,
) -> (StatusCode, HeaderMap, Vec<u8>) {
use tower::ServiceExt;
let mut req = Request::builder().method(method).uri(path);
if let Some(range) = range {
req = req.header(header::RANGE, range);
}
let resp = super::super::volume_server::build_public_router(state.clone())
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, headers, body)
}
/// A GET whose needle read is parked must not hold the store lock: a
/// writer, here a real append to the same volume, must get through.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_needle_read_runs_off_the_store_lock() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
use std::sync::mpsc;
use std::time::Duration;
const ID: u64 = 0x6e7a_0001;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"parked needle".to_vec();
let path = put_test_needle(&state, ID, &data);
let (entered_tx, entered_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Mutex::new(release_rx);
let _hook = needle_read_hook::register(NeedleId(ID), move |_| {
let _ = entered_tx.send(());
// Returns once released, or at once when the sender is gone.
let _ = release_rx.lock().unwrap().recv();
});
let get = tokio::spawn({
let state = state.clone();
async move { send_read(&state, Method::GET, &path, None).await }
});
tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(10)))
.await
.unwrap()
.expect("the GET never reached its needle read");
let try_write_ok = state.store.try_write().is_ok();
let (wrote_tx, wrote_rx) = mpsc::channel();
std::thread::spawn({
let state = state.clone();
move || {
put_test_needle(&state, ID + 1, b"written meanwhile");
let _ = wrote_tx.send(());
}
});
let write_done = tokio::task::spawn_blocking(move || {
wrote_rx.recv_timeout(Duration::from_secs(5)).is_ok()
})
.await
.unwrap();
drop(release_tx);
assert!(
try_write_ok,
"a parked GET read must not hold the store lock"
);
assert!(write_done, "an append must not wait for a parked GET read");
let (status, _, body) = get.await.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body, data);
}
/// A small needle is read from disk once, not once for its meta and
/// again in full; a large one never reads its payload before streaming.
#[tokio::test]
async fn test_get_reads_small_needle_once_and_large_needle_meta_only() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const SMALL: u64 = 0x6e7a_0101;
const LARGE: u64 = 0x6e7a_0102;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let small = b"small needle".to_vec();
let large: Vec<u8> = (0..(STREAMING_THRESHOLD as usize + 4096))
.map(|i| (i % 251) as u8)
.collect();
let small_path = put_test_needle(&state, SMALL, &small);
let large_path = put_test_needle(&state, LARGE, &large);
let small_reads = Arc::new(Mutex::new(Vec::new()));
let large_reads = Arc::new(Mutex::new(Vec::new()));
let _small_hook = needle_read_hook::register(NeedleId(SMALL), {
let reads = small_reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let _large_hook = needle_read_hook::register(NeedleId(LARGE), {
let reads = large_reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let (status, _, body) = send_read(&state, Method::GET, &small_path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, small);
let reads = small_reads.lock().unwrap().clone();
assert_eq!(reads.len(), 1, "reads of the small needle: {reads:?}");
let (status, _, body) = send_read(&state, Method::GET, &large_path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, large);
let reads = large_reads.lock().unwrap().clone();
assert!(
!reads.is_empty() && reads.iter().all(|&len| len < 64 * 1024),
"the large needle's payload must not be read before streaming: {reads:?}"
);
}
/// HEAD and ranged reads keep answering from the needle meta and the
/// data file, and a missing or wrong-cookie needle is still a 404.
#[tokio::test]
async fn test_get_head_range_and_not_found_after_single_read() {
const ID: u64 = 0x6e7a_0201;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let path = put_test_needle(&state, ID, &data);
let (status, headers, body) = send_read(&state, Method::HEAD, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], data.len().to_string());
assert!(headers.contains_key(header::ETAG));
let (status, headers, body) =
send_read(&state, Method::GET, &path, Some("bytes=10-19")).await;
assert_eq!(status, StatusCode::PARTIAL_CONTENT);
assert_eq!(body, &data[10..20]);
assert_eq!(
headers["Content-Range"],
format!("bytes 10-19/{}", data.len())
);
let wrong_cookie = format!("/1,{:x}{:08x}", ID, TEST_COOKIE ^ 1);
let (status, _, _) = send_read(&state, Method::GET, &wrong_cookie, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let missing = format!("/1,{:x}{:08x}", ID + 1, TEST_COOKIE);
let (status, _, _) = send_read(&state, Method::GET, &missing, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// A large compressed needle cannot be streamed as stored: its meta is
/// read first, then the payload exactly once.
#[tokio::test]
async fn test_get_large_compressed_needle_reads_payload_once() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const ID: u64 = 0x6e7a_0301;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
// Incompressible, so the stored gzip stays above the stream threshold.
let mut x = 0x2545_f491_u32;
let plain: Vec<u8> = (0..(STREAMING_THRESHOLD as usize + 64 * 1024))
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x as u8
})
.collect();
let gz = try_gzip_data(&plain).unwrap();
assert!(gz.len() > STREAMING_THRESHOLD as usize);
let path = put_test_needle_with(&state, ID, &gz, |n| n.set_is_compressed());
let reads = Arc::new(Mutex::new(Vec::new()));
let _hook = needle_read_hook::register(NeedleId(ID), {
let reads = reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let (status, _, body) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, plain);
let reads = reads.lock().unwrap().clone();
let full = reads.iter().filter(|&&len| len >= gz.len()).count();
assert_eq!(full, 1, "reads: {reads:?}");
}
/// The single full read still verifies the needle checksum.
#[tokio::test]
async fn test_get_small_needle_with_bad_checksum_fails() {
const ID: u64 = 0x6e7a_0401;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data = b"checksummed payload, to be corrupted".to_vec();
let path = put_test_needle(&state, ID, &data);
let dat = tmp.path().join("1.dat");
let mut bytes = std::fs::read(&dat).unwrap();
let at = bytes
.windows(data.len())
.position(|w| w == data.as_slice())
.unwrap();
bytes[at] ^= 0xff;
std::fs::write(&dat, bytes).unwrap();
let (status, _, _) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
}
File diff suppressed because it is too large Load Diff
+3 -36
View File
@@ -1,6 +1,5 @@
use tonic::Status;
use crate::remote_storage::s3_tier::TierError;
use crate::storage::volume::VolumeError;
#[cfg(unix)]
@@ -24,12 +23,8 @@ impl From<VolumeError> for Status {
fn from(err: VolumeError) -> Self {
let message = err.to_string();
match err {
VolumeError::NotFound
| VolumeError::VolumeNotFound(_)
| VolumeError::Tier(TierError::NotFound(_)) => Status::not_found(message),
VolumeError::ReadOnly(_) | VolumeError::NotEmpty => {
Status::failed_precondition(message)
}
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),
@@ -71,14 +66,7 @@ mod tests {
Code::NotFound
);
assert_eq!(code(VolumeError::NotFound), Code::NotFound);
assert_eq!(
code(VolumeError::ReadOnly(VolumeId(7))),
Code::FailedPrecondition
);
assert_eq!(
VolumeError::ReadOnly(VolumeId(7)).to_string(),
"volume 7 is read only"
);
assert_eq!(code(VolumeError::ReadOnly), Code::FailedPrecondition);
assert_eq!(
code(VolumeError::InsufficientSpace {
vid: VolumeId(7),
@@ -89,27 +77,6 @@ mod tests {
);
assert_eq!(code(VolumeError::AlreadyExists), Code::AlreadyExists);
assert_eq!(code(VolumeError::NotInitialized), Code::Internal);
assert_eq!(
code(TierError::NotFound("gone".into()).into()),
Code::NotFound
);
for tier in [
TierError::Io("io".into()),
TierError::RuntimeUnavailable("rt".into()),
TierError::Aborted("bye".into()),
] {
assert_eq!(code(tier.into()), Code::Internal);
}
let status = status_with_context(
"backend s3.default copy file /data/1.dat",
TierError::NotFound("failed to head object k: NotFound".into()).into(),
);
assert_eq!(status.code(), Code::NotFound);
assert_eq!(
status.message(),
"backend s3.default copy file /data/1.dat: failed to head object k: NotFound"
);
let status = status_with_context(
"compact volume 7",
+24 -72
View File
@@ -95,41 +95,20 @@ struct Snapshot {
encode_ts_ns: i64,
}
/// Why a distributed EC read has no needle to return.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EcMiss {
NotFound,
/// Tombstoned in the local `.ecx`/`.ecj`, or reported deleted by a peer.
Deleted,
VolumeNotFound,
}
/// Top-level entry point. Returns `Ok(None)` for any miss — absent,
/// deleted, or volume gone; errors propagate as `io::Error`.
/// Top-level entry point. Returns `Ok(None)` for "not found" (matches
/// Go's `ReadEcShardNeedle`); errors propagate as `io::Error`.
pub async fn read_ec_shard_needle_distributed(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<Option<Needle>> {
Ok(read_ec_shard_needle_or_miss(state, vid, needle_id)
.await?
.ok())
}
/// Like `read_ec_shard_needle_distributed`, but says why there is no needle,
/// as Go's `ReadEcShardNeedle` tells `ErrorDeleted` from not-found.
pub async fn read_ec_shard_needle_or_miss(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<Result<Needle, EcMiss>> {
// Phase A — under the Store read lock, locate the needle, compute
// intervals, and read any locally-mounted shard intervals. We must
// not `.await` while holding this guard (std::sync::RwLockReadGuard
// is !Send).
let mut snapshot = match snapshot_under_lock(state, vid, needle_id)? {
Ok(s) => s,
Err(miss) => return Ok(Err(miss)),
Some(s) => s,
None => return Ok(None),
};
// Phase B — refresh the shard_locations cache from the master if
@@ -231,11 +210,17 @@ pub async fn read_ec_shard_needle_or_miss(
.collect()
.await;
// A peer reports the needle deleted (a cross-server window where the
// local index still shows it live): answer deleted rather than serving zeros.
let Some(assembled) = gather_intervals(fetched)? else {
return Ok(Err(EcMiss::Deleted));
};
let mut assembled: Vec<Vec<u8>> = Vec::with_capacity(fetched.len());
for res in fetched {
let (buf, is_deleted) = res?;
// A peer reports the needle deleted (a cross-server window where the
// local index still shows it live): treat as not-found rather than
// serving zeros, mirroring Go's ErrorDeleted.
if is_deleted {
return Ok(None);
}
assembled.push(buf);
}
// Phase D — assemble and parse the Needle. Mirrors the tail of
// `EcVolume::read_ec_shard_needle`.
@@ -267,20 +252,7 @@ pub async fn read_ec_shard_needle_or_miss(
snapshot.version,
)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{}", e)))?;
Ok(Ok(n))
}
/// `None` when any holder reported the needle deleted. That outranks another
/// interval's error: deletes are never invented, so the needle is gone either way.
fn gather_intervals(fetched: Vec<io::Result<(Vec<u8>, bool)>>) -> io::Result<Option<Vec<Vec<u8>>>> {
if fetched.iter().any(|r| matches!(r, Ok((_, true)))) {
return Ok(None);
}
fetched
.into_iter()
.map(|r| r.map(|(buf, _)| buf))
.collect::<io::Result<_>>()
.map(Some)
Ok(Some(n))
}
/// What one EC delete RPC carries — `VolumeEcBlobDeleteRequest` minus tonic.
@@ -892,10 +864,11 @@ fn snapshot_under_lock(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<Result<Snapshot, EcMiss>> {
) -> io::Result<Option<Snapshot>> {
let store = state.store.read().unwrap();
let Some(ecv) = store.find_ec_volume(vid) else {
return Ok(Err(EcMiss::VolumeNotFound));
let ecv = match store.find_ec_volume(vid) {
Some(v) => v,
None => return Ok(None),
};
// Reuse EcVolume::locate_needle for offset/size resolution AND
@@ -903,17 +876,11 @@ fn snapshot_under_lock(
// local-only read path uses, so we stay byte-identical on the
// shard-size + interval boundaries. locate_needle applies the runtime
// delete mask, which is correct for serving reads.
let Some((offset, size, intervals)) = ecv.locate_needle(needle_id)? else {
// locate_needle folds a tombstone into not-found.
let deleted =
matches!(ecv.find_needle_from_ecx(needle_id)?, Some((_, s)) if s.is_deleted());
return Ok(Err(if deleted {
EcMiss::Deleted
} else {
EcMiss::NotFound
}));
let (offset, size, intervals) = match ecv.locate_needle(needle_id)? {
Some(v) => v,
None => return Ok(None),
};
build_snapshot(ecv, offset, size, &intervals).map(Ok)
build_snapshot(ecv, offset, size, &intervals).map(Some)
}
/// Like `snapshot_under_lock`, but locates intervals from the RAW .ecx
@@ -1823,21 +1790,6 @@ async fn drain_copy_stream(
mod tests {
use super::*;
#[test]
fn gather_intervals_puts_a_reported_deletion_ahead_of_errors() {
let failed = || Err(io::Error::other("shard unreachable"));
assert!(
gather_intervals(vec![failed(), Ok((Vec::new(), true))])
.unwrap()
.is_none()
);
assert!(gather_intervals(vec![failed(), Ok((vec![1], false))]).is_err());
assert_eq!(
gather_intervals(vec![Ok((vec![1], false)), Ok((vec![2], false))]).unwrap(),
Some(vec![vec![1], vec![2]])
);
}
fn locations(count: usize) -> HashMap<ShardId, Vec<String>> {
(0..count)
.map(|sid| (sid as ShardId, vec!["127.0.0.1:8080".to_string()]))
+4 -65
View File
@@ -3,8 +3,8 @@
//! Instead of each upload handler directly calling `write_needle`, writes are
//! submitted to a queue. A background worker drains the queue in batches (up to
//! 128 entries), groups them by volume ID, and processes them together under a
//! single store lock. Durable writes to a volume share their .dat and .idx
//! flushes (see `Volume::write_needles_grouped`).
//! single store lock. Requests that asked for `fsync` are flushed by
//! `write_needle` itself, one flush per durable write.
use std::sync::Arc;
@@ -159,12 +159,8 @@ fn process_batch(state: Arc<VolumeServerState>, batch: Vec<WriteRequest>) {
let mut store = state.store.write().unwrap();
for (vid, entries) in groups {
let (mut writes, senders): (Vec<_>, Vec<_>) = entries
.into_iter()
.map(|(needle, fsync, response_tx)| ((needle, fsync), response_tx))
.unzip();
let results = store.write_volume_needles(vid, &mut writes);
for (response_tx, result) in senders.into_iter().zip(results) {
for (mut needle, fsync, response_tx) in entries {
let result = store.write_volume_needle(vid, &mut needle, fsync);
// Send result back; ignore error if receiver dropped.
let _ = response_tx.send(result);
}
@@ -316,63 +312,6 @@ mod tests {
}
}
/// The queue hands a volume's batch to the grouped path, so ten durable
/// writes cost one .dat sync and one .idx sync, not ten of each.
#[test]
fn test_process_batch_group_commits_fsync_writes() {
use crate::config::MinFreeSpace;
use crate::storage::types::{DiskType, NeedleId};
use crate::storage::volume::VolumeSpec;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = make_test_state();
{
let mut store = state.store.write().unwrap();
store
.add_location(
dir,
dir,
10,
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
store
.add_volume(VolumeId(1), DiskType::HardDrive, &VolumeSpec::default())
.unwrap();
}
let mut receivers = Vec::new();
let batch = (1..=10u64)
.map(|id| {
let (response_tx, response_rx) = oneshot::channel();
receivers.push(response_rx);
WriteRequest {
volume_id: VolumeId(1),
needle: Needle {
id: NeedleId(id),
cookie: 0x1111.into(),
data: vec![id as u8; 8],
data_size: 8,
..Needle::default()
},
fsync: true,
response_tx,
}
})
.collect();
process_batch(state.clone(), batch);
for mut rx in receivers {
assert!(matches!(rx.try_recv().unwrap(), Ok((_, _, false))));
}
let store = state.store.read().unwrap();
let (_, vol) = store.find_volume(VolumeId(1)).unwrap();
assert_eq!(vol.sync_counts_for_test(), (1, 1));
}
#[tokio::test]
async fn test_write_queue_dropped_sender() {
// When the queue is dropped, subsequent submits should fail gracefully.
+13 -69
View File
@@ -18,7 +18,7 @@ use crate::storage::erasure_coding::ec_shard::{
DATA_SHARDS_COUNT, ERASURE_CODING_LARGE_BLOCK_SIZE, ERASURE_CODING_SMALL_BLOCK_SIZE,
EcVolumeShard, ShardId,
};
use crate::storage::erasure_coding::ec_volume::{EcVolume, is_usable_ecx_file};
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::types::*;
@@ -479,13 +479,6 @@ impl DiskLocation {
if self.idx_directory != self.directory {
remove_bitrot_sidecars(&idx_base)?;
}
// Staged 2PC generations (<base>.ecNN.v<N>, versioned .ecx/.ecj/.vif)
// belong to this volume's EC state too; leaving them orphans the files.
crate::storage::erasure_coding::ec_shard::remove_ec_generation_files(&base, 0)?;
if self.idx_directory != self.directory {
crate::storage::erasure_coding::ec_shard::remove_ec_generation_files(&idx_base, 0)?;
}
Ok(())
}
@@ -605,20 +598,13 @@ impl DiskLocation {
&mut self,
vid: VolumeId,
only_empty: bool,
only_garbage: bool,
keep_remote_data: bool,
) -> Result<(), VolumeError> {
// Refuse before removing: a refused destroy must leave it mounted.
if let Some(v) = self.volumes.get(&vid)
&& v.is_compacting()
{
return Err(v.compacting_error());
}
if let Some(mut v) = self.volumes.remove(&vid) {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
v.destroy(only_empty, only_garbage, keep_remote_data)?;
v.destroy(only_empty, keep_remote_data)?;
Ok(())
} else {
Err(VolumeError::NotFound)
@@ -639,7 +625,7 @@ impl DiskLocation {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
if let Err(e) = v.destroy(false, false, false) {
if let Err(e) = v.destroy(false, false) {
warn!(volume_id = vid.0, error = %e, "delete collection: failed to destroy volume");
}
}
@@ -793,17 +779,21 @@ impl DiskLocation {
/// Mirrors `DiskLocation.HasEcxFileOnDisk` in
/// `weed/storage/disk_location_ec.go`. Skips entries that are
/// directories so a stray dir named `<collection>_<vid>.ecx` doesn't
/// register as a present index file. A 0-byte `.ecx` is a corrupt stub
/// left by a failed EC distribute copy; it must not steer placement
/// toward this disk, so it counts as absent (Go requires `Size() > 0`).
/// register as a present index file.
pub fn has_ecx_file_on_disk(&self, collection: &str, vid: VolumeId) -> bool {
let idx_base = volume_file_name(&self.idx_directory, collection, vid);
if is_usable_ecx_file(&format!("{}.ecx", idx_base)) {
let idx_path = format!("{}.ecx", idx_base);
if let Ok(meta) = fs::metadata(&idx_path)
&& !meta.is_dir()
{
return true;
}
if self.idx_directory != self.directory {
let data_base = volume_file_name(&self.directory, collection, vid);
if is_usable_ecx_file(&format!("{}.ecx", data_base)) {
let data_path = format!("{}.ecx", data_base);
if let Ok(meta) = fs::metadata(&data_path)
&& !meta.is_dir()
{
return true;
}
}
@@ -815,20 +805,6 @@ impl DiskLocation {
self.ec_volumes.remove(&vid)
}
/// Drop the in-memory EC volume for vid and close its descriptors without
/// deleting files, so a following unlink frees the inodes instead of
/// leaving open fds serving the old bytes. Mirrors Go's unloadEcVolume.
pub fn unload_ec_volume(&mut self, vid: VolumeId) {
if let Some(mut ec_vol) = self.ec_volumes.remove(&vid) {
for _ in 0..ec_vol.shard_count() {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&ec_vol.collection, "ec_shards"])
.dec();
}
ec_vol.close();
}
}
/// Mount EC shards for a volume on this location.
///
/// `source_disk_type` is the source volume's disk type carried on the
@@ -1761,7 +1737,7 @@ mod tests {
.unwrap();
assert_eq!(loc.volumes_len(), 2);
loc.delete_volume(VolumeId(1), false, false, false).unwrap();
loc.delete_volume(VolumeId(1), false, false).unwrap();
assert_eq!(loc.volumes_len(), 1);
assert!(loc.find_volume(VolumeId(1)).is_none());
}
@@ -1814,34 +1790,6 @@ mod tests {
assert!(loc.find_volume(VolumeId(3)).is_some());
}
/// A 0-byte `.ecx` is the stub a failed EC distribute copy leaves behind.
/// Go's HasEcxFileOnDisk requires Size() > 0 so the stub cannot pin
/// placement to a disk that has no usable index.
#[test]
fn test_has_ecx_file_on_disk_ignores_zero_byte_stub() {
let tmp = TempDir::new().unwrap();
let data = tmp.path().join("data");
let idx = tmp.path().join("idx");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(&idx).unwrap();
let loc = DiskLocation::new(
data.to_str().unwrap(),
idx.to_str().unwrap(),
10,
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
fs::write(idx.join("pics_7.ecx"), b"").unwrap();
assert!(!loc.has_ecx_file_on_disk("pics", VolumeId(7)));
// A real index in the data dir still counts, stub or no stub.
fs::write(data.join("pics_7.ecx"), [0u8; 16]).unwrap();
assert!(loc.has_ecx_file_on_disk("pics", VolumeId(7)));
}
#[test]
fn test_disk_location_delete_collection_removes_ec_volumes() {
let tmp = TempDir::new().unwrap();
@@ -1858,8 +1806,6 @@ mod tests {
let shard_path = format!("{}/pics_7.ec00", dir);
std::fs::write(&shard_path, b"ec-shard").unwrap();
// An EC volume needs its .ecx to mount.
std::fs::write(format!("{}/pics_7.ecx", dir), [0u8; 16]).unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0], "").unwrap();
assert!(loc.has_ec_volume(VolumeId(7)));
@@ -1897,7 +1843,6 @@ 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();
std::fs::write(format!("{}/pics_7.ecx", dir), [0u8; 16]).unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0], "ssd")
.unwrap();
{
@@ -1997,7 +1942,6 @@ mod tests {
// A collection name unique to this test: the gauge is process-global
// and sibling tests running in parallel touch other labels.
std::fs::write(format!("{}/dupmount_11.ec00", dir), b"shard bytes").unwrap();
std::fs::write(format!("{}/dupmount_11.ecx", dir), [0u8; 16]).unwrap();
let gauge = crate::metrics::VOLUME_GAUGE.with_label_values(&["dupmount", "ec_shards"]);
let before = gauge.get();
@@ -3,12 +3,10 @@
//! Rebuilds the original .dat + .idx files from data shards (.ec00-.ec09)
//! and the sorted index (.ecx) + deletion journal (.ecj).
use std::collections::HashSet;
use std::fs::File;
use std::io::{self, Read, Write};
use crate::storage::erasure_coding::ec_shard::*;
use crate::storage::erasure_coding::ec_volume::read_ecj_ids;
use crate::storage::idx;
use crate::storage::needle::needle::get_actual_size;
use crate::storage::super_block::SUPER_BLOCK_SIZE;
@@ -22,21 +20,18 @@ use crate::storage::volume::{fsync_dir, volume_file_name};
/// `dir` is used both for reading `.ec00` and `.ecx`. For split-disk
/// reconciled volumes call [`find_dat_file_size_with_dirs`] instead.
pub fn find_dat_file_size(dir: &str, collection: &str, volume_id: VolumeId) -> io::Result<i64> {
let deleted = read_ecj_deletions(&[dir], collection, volume_id)?;
find_dat_file_size_with_dirs(dir, dir, collection, volume_id, &deleted)
find_dat_file_size_with_dirs(dir, dir, collection, volume_id)
}
/// Like [`find_dat_file_size`] but lets the caller pass separate dirs
/// for `.ec00` (the data shard) and `.ecx` (the sealed index). This
/// is the form needed when shards are split across data dirs and the
/// `.ecx` lives on a sibling disk's idx dir (#9252). Needles in `deleted`
/// count as deleted.
/// `.ecx` lives on a sibling disk's idx dir (#9252).
pub fn find_dat_file_size_with_dirs(
ec00_dir: &str,
ecx_dir: &str,
collection: &str,
volume_id: VolumeId,
deleted: &HashSet<NeedleId>,
) -> io::Result<i64> {
let ec00_base = volume_file_name(ec00_dir, collection, volume_id);
let ecx_base = volume_file_name(ecx_dir, collection, volume_id);
@@ -58,9 +53,9 @@ pub fn find_dat_file_size_with_dirs(
for i in 0..entry_count {
let start = i * NEEDLE_MAP_ENTRY_SIZE;
let (key, offset, size) =
let (_, offset, size) =
idx_entry_from_bytes(&ecx_data[start..start + NEEDLE_MAP_ENTRY_SIZE]);
if size.is_deleted() || deleted.contains(&key) {
if size.is_deleted() {
continue;
}
let entry_stop = offset.to_actual_offset() + get_actual_size(size, version);
@@ -72,48 +67,6 @@ pub fn find_dat_file_size_with_dirs(
Ok(dat_size)
}
/// Whether the `.ecx` in `ecx_dir` indexes a needle deleted neither there nor
/// in `deleted`.
pub fn has_live_needles(
ecx_dir: &str,
collection: &str,
volume_id: VolumeId,
deleted: &HashSet<NeedleId>,
) -> io::Result<bool> {
let ecx_base = volume_file_name(ecx_dir, collection, volume_id);
let ecx_data = std::fs::read(format!("{}.ecx", ecx_base))?;
let (entries, _) = ecx_data.as_chunks::<NEEDLE_MAP_ENTRY_SIZE>();
Ok(entries.iter().any(|entry| {
let (key, _, size) = idx_entry_from_bytes(entry);
!size.is_deleted() && !deleted.contains(&key)
}))
}
/// Distinct needle ids journaled in the `.ecj` of any of `dirs`. Go folds the
/// journal into the `.ecx` (RebuildEcxFile) before a decode; reading it leaves
/// the sealed index untouched. Only NotFound means "no journal".
pub fn read_ecj_deletions(
dirs: &[&str],
collection: &str,
volume_id: VolumeId,
) -> io::Result<HashSet<NeedleId>> {
let mut ids = HashSet::new();
for (i, dir) in dirs.iter().enumerate() {
if dirs[..i].contains(dir) {
continue;
}
let path = format!("{}.ecj", volume_file_name(dir, collection, volume_id));
let file = match File::open(&path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
let len = file.metadata()?.len();
read_ecj_ids(&file, len, &mut ids)?;
}
Ok(ids)
}
/// What it takes to rebuild a volume's .dat from its EC data shards.
///
/// Mirrors Go's `WriteDatFile(baseFileName, datFileSize,
@@ -314,61 +267,51 @@ pub fn write_dat_file_from_shards(spec: &DatRebuild<'_>) -> io::Result<()> {
/// Write .idx file from .ecx index + .ecj deletion journal.
///
/// See [`write_idx_file_from_ec_index_with_dirs`]; everything lives in `dir`.
/// Copies sorted .ecx entries to .idx, then appends tombstones for
/// deleted needles from .ecj.
pub fn write_idx_file_from_ec_index(
dir: &str,
collection: &str,
volume_id: VolumeId,
) -> io::Result<()> {
let deleted = read_ecj_deletions(&[dir], collection, volume_id)?;
let dat_file_size = find_dat_file_size_with_dirs(dir, dir, collection, volume_id, &deleted)?;
write_idx_file_from_ec_index_with_dirs(dir, dir, collection, volume_id, &deleted, dat_file_size)
}
/// Write the `.idx` for a `.dat` decoded to `dat_file_size` bytes, from the
/// `.ecx` in `ecx_dir`, into `idx_dir`.
///
/// Copies the `.ecx` rows, then appends one tombstone per row whose needle is
/// in `deleted`. A deleted needle at or past `dat_file_size` was cut from the
/// `.dat`, so its row is dropped: a row pointing past the end of the `.dat`
/// makes the volume load read-only.
pub fn write_idx_file_from_ec_index_with_dirs(
ecx_dir: &str,
idx_dir: &str,
collection: &str,
volume_id: VolumeId,
deleted: &HashSet<NeedleId>,
dat_file_size: i64,
) -> io::Result<()> {
let ecx_path = format!("{}.ecx", volume_file_name(ecx_dir, collection, volume_id));
let idx_path = format!("{}.idx", volume_file_name(idx_dir, collection, volume_id));
let base = volume_file_name(dir, collection, volume_id);
let ecx_path = format!("{}.ecx", base);
let ecj_path = format!("{}.ecj", base);
let idx_path = format!("{}.idx", base);
// Write to a temp file and atomically rename into place, so a crash
// mid-write never leaves a partial .idx at the final name beside the
// source shards.
let tmp_path = format!("{}.tmp", idx_path);
let write_result = (|| -> io::Result<()> {
let mut ecx_file = File::open(&ecx_path)?;
let mut idx_file = io::BufWriter::new(File::create(&tmp_path)?);
let mut tombstoned = Vec::new();
idx::walk_index_file(&mut ecx_file, 0, |key, offset, size| {
let is_deleted = size.is_deleted() || deleted.contains(&key);
if is_deleted && offset.to_actual_offset() >= dat_file_size {
return Ok(());
// Copy .ecx to the temp .idx
std::fs::copy(&ecx_path, &tmp_path)?;
// Append deletions from .ecj as tombstones. Read the journal directly
// and treat only NotFound as "no journal": Path::exists would also
// swallow a permission/IO error and silently skip deletions, which
// would resurrect deleted needles as live.
let mut idx_file = std::fs::OpenOptions::new().append(true).open(&tmp_path)?;
match std::fs::read(&ecj_path) {
Ok(ecj_data) => {
let count = ecj_data.len() / NEEDLE_ID_SIZE;
for i in 0..count {
let start = i * NEEDLE_ID_SIZE;
let needle_id = NeedleId::from_bytes(&ecj_data[start..start + NEEDLE_ID_SIZE]);
idx::write_index_entry(
&mut idx_file,
needle_id,
Offset::default(),
TOMBSTONE_FILE_SIZE,
)?;
}
}
idx::write_index_entry(&mut idx_file, key, offset, size)?;
if !size.is_deleted() && deleted.contains(&key) {
tombstoned.push(key);
}
Ok(())
})?;
for key in tombstoned {
idx::write_index_entry(&mut idx_file, key, Offset::default(), TOMBSTONE_FILE_SIZE)?;
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
// fsync, rename, then fsync the dir so the decoded .idx is durable and
// atomically published before the caller deletes the source shards.
let idx_file = idx_file.into_inner().map_err(|e| e.into_error())?;
idx_file.sync_all()?;
drop(idx_file);
// Windows rename does not replace an existing file on every version;
@@ -751,43 +694,4 @@ mod tests {
.is_err()
);
}
/// A journal many chunks long that repeats a few ids reads back as those
/// ids, from each dir once, whatever its length.
#[test]
fn test_read_ecj_deletions_collects_distinct_ids_across_dirs() {
let tmp = TempDir::new().unwrap();
let data = tmp.path().join("data");
let idx = tmp.path().join("idx");
let missing = tmp.path().join("missing");
std::fs::create_dir_all(&data).unwrap();
std::fs::create_dir_all(&idx).unwrap();
let (data, idx, missing) = (
data.to_str().unwrap(),
idx.to_str().unwrap(),
missing.to_str().unwrap(),
);
let entry = |id: u64| {
let mut buf = [0u8; NEEDLE_ID_SIZE];
NeedleId(id).to_bytes(&mut buf);
buf
};
// Past two load chunks of three repeating ids, then an id only in the
// last chunk and a torn trailing record.
let mut ecj = Vec::new();
while ecj.len() <= 2 * (1 << 20) {
for id in [1, 2, 3] {
ecj.extend_from_slice(&entry(id));
}
}
ecj.extend_from_slice(&entry(7));
ecj.extend_from_slice(&entry(8)[..3]);
std::fs::write(format!("{idx}/1.ecj"), &ecj).unwrap();
std::fs::write(format!("{data}/1.ecj"), entry(9)).unwrap();
let ids = read_ecj_deletions(&[data, idx, idx, missing], "", VolumeId(1)).unwrap();
let expected: HashSet<NeedleId> = [1, 2, 3, 7, 9].into_iter().map(NeedleId).collect();
assert_eq!(ids, expected);
}
}
@@ -813,7 +813,7 @@ impl EncodeRun<'_> {
// explicitly.
for (i, buf) in self.buffers[..self.data_shards].iter_mut().enumerate() {
let read_offset = offset + (i * block_size) as u64;
let n = crate::storage::io::read_full_at(self.dat_file, buf, read_offset)?;
let n = read_at_most(self.dat_file, buf, read_offset)?;
buf[n..].fill(0);
}
@@ -834,6 +834,19 @@ impl EncodeRun<'_> {
}
}
/// 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)?;
if r == 0 {
break;
}
n += r;
}
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,14 +87,14 @@ impl EcVolumeShard {
Ok(())
}
/// Read data at a specific offset, filling `buf` unless the shard ends first.
/// Read data at a specific offset.
pub fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
let file = self
.ecd_file
.as_ref()
.ok_or_else(|| io::Error::other("shard file not open"))?;
crate::storage::io::read_full_at(file, buf, offset)
crate::storage::io::read_at(file, buf, offset)
}
/// Write data to the shard file (appends).
@@ -195,104 +195,6 @@ impl ShardBits {
}
}
/// Parses the generation of a 2PC-staged `<base>.v<N>` file: `None` means the
/// name is not a generation file of `base`.
pub fn ec_file_generation(name: &str, base: &str) -> Option<u32> {
let suffix = name.strip_prefix(&format!("{}.v", base))?;
match suffix.parse::<u32>() {
Ok(g) if g > 0 => Some(g),
_ => None,
}
}
/// Removes 2PC generation files staged under `base`:
/// `<base>.ecNN.v<N>`, `<base>.ecx.v<N>`, `<base>.ecj.v<N>`, `<base>.ecsum.v<N>`
/// and `<base>.vif.v<N>`. `generations_older_than == 0` removes every
/// generation; otherwise only generations strictly below it. Returns the
/// first real removal failure. Mirrors Go's `RemoveEcGenerationFiles`.
pub fn remove_ec_generation_files(base: &str, generations_older_than: u32) -> io::Result<()> {
let path = std::path::Path::new(base);
let (Some(parent), Some(fname)) = (path.parent(), path.file_name()) else {
return Ok(());
};
let ec_prefix = format!("{}.ec", fname.to_string_lossy());
let vif_name = format!("{}.vif", fname.to_string_lossy());
let mut first_err: Option<io::Error> = None;
let mut record = |res: io::Result<()>| {
if let Err(e) = res
&& first_err.is_none()
{
first_err = Some(e);
}
};
match fs::read_dir(parent) {
Ok(entries) => {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
// A skipped entry means an incomplete sweep; report it
// instead of pretending the cleanup finished.
record(Err(e));
continue;
}
};
let name = entry.file_name().to_string_lossy().into_owned();
let Some((artifact, _)) = name.rsplit_once(".v") else {
continue;
};
if artifact != vif_name && !artifact.starts_with(&ec_prefix) {
continue;
}
let Some(generation) = ec_file_generation(&name, artifact) else {
continue;
};
if generations_older_than > 0 && generation >= generations_older_than {
continue;
}
record(match fs::remove_file(entry.path()) {
Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
});
}
}
Err(e) if e.kind() != io::ErrorKind::NotFound => record(Err(e)),
Err(_) => {}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Removes every staged generation `<shard_file>.v<N>` of one shard file.
/// Returns true when at least one generation file was removed.
pub fn remove_ec_shard_generations(shard_file: &str) -> io::Result<bool> {
let path = std::path::Path::new(shard_file);
let (Some(parent), Some(fname)) = (path.parent(), path.file_name()) else {
return Ok(false);
};
let fname = fname.to_string_lossy().into_owned();
let mut removed = false;
match fs::read_dir(parent) {
Ok(entries) => {
for entry in entries {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if ec_file_generation(&name, &fname).is_some() {
match fs::remove_file(entry.path()) {
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
_ => removed = true,
}
}
}
}
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
Err(_) => {}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -48,29 +48,6 @@ pub(crate) struct ShardLocationCache {
/// A multiple of `NEEDLE_ID_SIZE`; 1 MiB is 131072 entries per syscall.
const ECJ_LOAD_CHUNK_BYTES: usize = 1 << 20;
/// Adds every whole needle id in the first `len` bytes of `ecj_file` to `ids`,
/// reading `ECJ_LOAD_CHUNK_BYTES` at a time; a trailing partial record is
/// ignored.
pub(crate) fn read_ecj_ids(
ecj_file: &File,
len: u64,
ids: &mut HashSet<NeedleId>,
) -> io::Result<()> {
let mut buf = vec![0u8; std::cmp::min(ECJ_LOAD_CHUNK_BYTES as u64, len) as usize];
let mut off: u64 = 0;
while off + NEEDLE_ID_SIZE as u64 <= len {
let mut want = std::cmp::min(ECJ_LOAD_CHUNK_BYTES as u64, len - off) as usize;
want -= want % NEEDLE_ID_SIZE;
// Positional read: the loader's handle is shared with journal appends.
read_exact_at(ecj_file, &mut buf[..want], off)?;
for entry in buf[..want].as_chunks::<NEEDLE_ID_SIZE>().0 {
ids.insert(NeedleId::from_bytes(entry));
}
off += want as u64;
}
Ok(())
}
/// An erasure-coded volume managing its local shards and index.
pub struct EcVolume {
pub volume_id: VolumeId,
@@ -406,26 +383,8 @@ pub fn validate_block_size(block_size: i64) -> io::Result<()> {
Ok(())
}
/// Size of the `.ecx` at `path`, or `None` when it is absent or a directory.
/// Mirrors Go's `statEcxSize`.
pub(crate) fn ecx_file_size(path: &str) -> Option<u64> {
match std::fs::metadata(path) {
Ok(meta) if !meta.is_dir() => Some(meta.len()),
_ => None,
}
}
/// Whether `path` is an `.ecx` that can steer placement, ownership or a copy:
/// a regular file with content. A 0-byte `.ecx` is what a failed EC distribute
/// copy leaves behind, and Go treats it as absent at every such decision
/// (`HasEcxFileOnDisk`, `findEcxIdxDirForVolume`, `indexEcxOwners`) so the scan
/// moves on to a sibling disk that may hold a valid index.
pub(crate) fn is_usable_ecx_file(path: &str) -> bool {
ecx_file_size(path).is_some_and(|size| size > 0)
}
impl EcVolume {
/// Create a new EcVolume. Opens the .ecx index (required) and the .ecj journal.
/// Create a new EcVolume. Loads .ecx index and .ecj journal if present.
pub fn new(
dir: &str,
dir_idx: &str,
@@ -508,62 +467,27 @@ impl EcVolume {
// Open .ecx file (sorted index) in read/write mode for in-place deletion marking.
// Matches Go which opens ecx for writing via MarkNeedleDeleted.
//
// Resolve it the way Go's NewEcVolume does: prefer a non-empty copy,
// the one co-located with the shard data first, then the caller's
// index directory — either the shared -dir.idx dir or a sibling disk
// that owns the .ecx when this disk holds only a 0-byte stub left by an
// interrupted copy. A 0-byte .ecx is also a legitimate empty index, so
// it yields only to a non-empty copy elsewhere, never to a mere
// absence. No .ecx at all fails the mount with NotFound (Go wraps
// os.ErrNotExist): an EcVolume without an index would advertise shards
// that no read can ever serve.
let local_ecx = format!(
"{}.ecx",
crate::storage::volume::volume_file_name(dir, collection, volume_id)
);
let shared_ecx = vol.ecx_file_name();
let local_size = ecx_file_size(&local_ecx);
let shared_size = if dir_idx != dir {
ecx_file_size(&shared_ecx)
} else {
None
};
let use_local = match (local_size, shared_size) {
(Some(n), _) if n > 0 => true,
(_, Some(n)) if n > 0 => {
tracing::debug!(
let ecx_path = vol.ecx_file_name();
if std::path::Path::new(&ecx_path).exists() {
let file = open_volume_file(OpenOptions::new().read(true).write(true), &ecx_path)?;
vol.ecx_file_size = file.metadata()?.len() as i64;
vol.ecx_file = Some(file);
} else if dir_idx != dir {
// Fall back to data directory if .ecx was created before -dir.idx was configured
let data_base = crate::storage::volume::volume_file_name(dir, collection, volume_id);
let fallback_ecx = format!("{}.ecx", data_base);
if std::path::Path::new(&fallback_ecx).exists() {
tracing::info!(
volume_id = volume_id.0,
"ecx not local at {}, using {}",
local_ecx,
shared_ecx
"ecx file not found in idx dir, falling back to data dir"
);
false
let file =
open_volume_file(OpenOptions::new().read(true).write(true), &fallback_ecx)?;
vol.ecx_file_size = file.metadata()?.len() as i64;
vol.ecx_file = Some(file);
vol.ecx_actual_dir = dir.to_string();
}
// Only 0-byte copies exist: an empty index, local first.
(Some(_), _) => true,
(None, Some(_)) => false,
(None, None) => {
let tried = if dir_idx != dir {
format!("{} (or {})", local_ecx, shared_ecx)
} else {
local_ecx
};
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("cannot open ec volume index {}: not found", tried),
));
}
};
let ecx_path = if use_local {
vol.ecx_actual_dir = dir.to_string();
local_ecx
} else {
shared_ecx
};
let file = open_volume_file(OpenOptions::new().read(true).write(true), &ecx_path)?;
vol.ecx_file_size = file.metadata()?.len() as i64;
vol.ecx_file = Some(file);
}
// Open .ecj file (deletion journal) — use ecx_actual_dir for consistency.
// Note: Go does NOT replay .ecj into .ecx at volume load (RebuildEcxFile
@@ -833,7 +757,38 @@ impl EcVolume {
// held the `deleted_needles` write lock for the whole scan, which on a
// bloated journal is the entire (unbounded) startup.
let mut loaded: HashSet<NeedleId> = HashSet::new();
read_ecj_ids(ecj_file, self.ecj_file_size as u64, &mut loaded)?;
let mut buf = vec![0u8; ECJ_LOAD_CHUNK_BYTES];
let end = self.ecj_file_size as u64;
let mut off: u64 = 0;
while off + NEEDLE_ID_SIZE as u64 <= end {
// Whole entries only; a trailing partial record is ignored, as the
// per-entry loop did by construction.
let mut want = std::cmp::min(ECJ_LOAD_CHUNK_BYTES as u64, end - off) as usize;
want -= want % NEEDLE_ID_SIZE;
if want == 0 {
break;
}
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
ecj_file.read_exact_at(&mut buf[..want], off)?;
}
#[cfg(windows)]
{
// Positional read so concurrent readers of the shared .ecj
// handle can't interleave seek/read. Mirrors the
// read_exact_at helper at the bottom of this file.
read_exact_at(ecj_file, &mut buf[..want], off)?;
}
#[cfg(not(any(unix, windows)))]
{
compile_error!("Platform not supported: only unix and windows are supported");
}
for entry in buf[..want].chunks_exact(NEEDLE_ID_SIZE) {
loaded.insert(NeedleId::from_bytes(entry));
}
off += want as u64;
}
let mut set = self
.deleted_needles
@@ -1909,63 +1864,6 @@ mod tests {
use super::*;
use tempfile::TempDir;
/// Go's NewEcVolume fails with os.ErrNotExist when neither directory has an
/// `.ecx`. Mounting anyway advertises shards every read then fails on with
/// "ecx file not open", and zeroes the size `add_shard`'s 0-byte guard needs.
#[test]
fn test_new_without_ecx_is_not_found() {
let data = TempDir::new().unwrap();
let idx = TempDir::new().unwrap();
let (dir, dir_idx) = (data.path().to_str().unwrap(), idx.path().to_str().unwrap());
std::fs::write(format!("{}/7.ec00", dir), b"shard").unwrap();
for idx_dir in [dir, dir_idx] {
let err = EcVolume::new(dir, idx_dir, "", VolumeId(7))
.err()
.expect("an EC volume without an .ecx must not mount");
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{}", err);
}
}
/// A 0-byte `.ecx` stub left by an interrupted copy yields to a non-empty
/// copy in the other directory, whichever side the stub is on.
#[test]
fn test_new_prefers_non_empty_ecx_over_zero_byte_stub() {
for stub_in_idx_dir in [true, false] {
let data = TempDir::new().unwrap();
let idx = TempDir::new().unwrap();
let (dir, dir_idx) = (data.path().to_str().unwrap(), idx.path().to_str().unwrap());
let (stub_dir, valid_dir) = if stub_in_idx_dir {
(dir_idx, dir)
} else {
(dir, dir_idx)
};
std::fs::write(format!("{}/7.ecx", stub_dir), b"").unwrap();
std::fs::write(
format!("{}/7.ecx", valid_dir),
vec![0u8; NEEDLE_MAP_ENTRY_SIZE],
)
.unwrap();
let vol = EcVolume::new(dir, dir_idx, "", VolumeId(7)).unwrap();
assert_eq!(vol.ecx_actual_dir(), valid_dir);
assert_eq!(vol.ecx_file_size, NEEDLE_MAP_ENTRY_SIZE as i64);
}
}
/// With no other copy a 0-byte `.ecx` is a legitimate empty index (a volume
/// whose needles were all deleted before encoding) and still mounts, as in Go.
#[test]
fn test_new_accepts_lone_zero_byte_ecx_as_empty_index() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
std::fs::write(format!("{}/7.ecx", dir), b"").unwrap();
let vol = EcVolume::new(dir, dir, "", VolumeId(7)).unwrap();
assert_eq!(vol.ecx_file_size, 0);
assert!(!is_usable_ecx_file(&vol.ecx_file_name()));
}
/// `destroy()` must remove co-located `.ecsum` sidecars (Go Destroy parity).
/// Without this, `collection.delete` leaves orphaned bitrot files that
/// inflate EC-health scanners after the shards are gone.
@@ -4660,7 +4558,7 @@ impl EcLocalShard {
.file
.as_ref()
.map_err(|e| io::Error::new(e.kind(), e.to_string()))?;
crate::storage::io::read_full_at(file, buf, offset)
crate::storage::io::read_at(file, buf, offset)
}
}
+6 -130
View File
@@ -21,26 +21,12 @@ where
let mut buf = vec![0u8; NEEDLE_MAP_ENTRY_SIZE * ROWS_TO_READ];
loop {
// Fill the batch before decoding: `read` may return a count that is
// not a multiple of the entry size, and a split entry would misalign
// every later row. Go is immune: `ReadAt` fills or errors.
let mut count = 0;
let mut eof = false;
while count < buf.len() {
match reader.read(&mut buf[count..]) {
Ok(0) => {
eof = true;
break;
}
Ok(n) => count += n,
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(ref e) if e.kind() == io::ErrorKind::UnexpectedEof => {
eof = true;
break;
}
Err(e) => return Err(e),
}
}
let count = match reader.read(&mut buf) {
Ok(0) => return Ok(()),
Ok(n) => n,
Err(ref e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e),
};
let mut i = 0;
while i + NEEDLE_MAP_ENTRY_SIZE <= count {
@@ -48,11 +34,6 @@ where
f(key, offset, size)?;
i += NEEDLE_MAP_ENTRY_SIZE;
}
// A trailing partial entry at EOF is ignored, as Go does on `io.EOF`.
if eof {
return Ok(());
}
}
}
@@ -196,111 +177,6 @@ mod tests {
data
}
/// Reader that hands back at most `chunk` bytes per `read`. 7 is coprime
/// with the 17-byte entry size, so nearly every read ends mid-entry. With
/// `interrupts`, every other call fails with `ErrorKind::Interrupted`.
struct ShortReader {
inner: Cursor<Vec<u8>>,
chunk: usize,
interrupts: bool,
interrupt_next: bool,
}
impl ShortReader {
fn new(data: Vec<u8>, interrupts: bool) -> Self {
ShortReader {
inner: Cursor::new(data),
chunk: 7,
interrupts,
interrupt_next: false,
}
}
}
impl Read for ShortReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.interrupt_next {
self.interrupt_next = false;
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
self.interrupt_next = self.interrupts;
let n = buf.len().min(self.chunk);
self.inner.read(&mut buf[..n])
}
}
impl Seek for ShortReader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.inner.seek(pos)
}
}
fn walk_all<R: Read + Seek>(reader: &mut R, start_from: u64) -> Vec<(NeedleId, i64, Size)> {
let mut collected = Vec::new();
walk_index_file(reader, start_from, |key, offset, size| {
collected.push((key, offset.to_actual_offset(), size));
Ok(())
})
.unwrap();
collected
}
/// More than one ROWS_TO_READ batch, so the walk crosses a buffer refill.
fn many_entries() -> Vec<(NeedleId, Offset, Size)> {
(0..(ROWS_TO_READ as u64 * 2 + 37))
.map(|i| {
(
NeedleId(i * 7 + 1),
Offset::from_actual_offset(i as i64 * 128),
Size(i as i32 + 1),
)
})
.collect()
}
#[test]
fn test_walk_index_file_short_reads_keep_alignment() {
let data = idx_bytes(&many_entries());
let expected = walk_all(&mut Cursor::new(data.clone()), 0);
assert_eq!(expected.len(), ROWS_TO_READ * 2 + 37);
let mut short = ShortReader::new(data, false);
assert_eq!(walk_all(&mut short, 0), expected);
}
#[test]
fn test_walk_index_file_retries_interrupted_reads() {
let data = idx_bytes(&many_entries());
let expected = walk_all(&mut Cursor::new(data.clone()), 0);
let mut short = ShortReader::new(data, true);
assert_eq!(walk_all(&mut short, 0), expected);
}
#[test]
fn test_walk_index_file_short_reads_start_from() {
let data = idx_bytes(&many_entries());
let expected = walk_all(&mut Cursor::new(data.clone()), 0);
let start = ROWS_TO_READ as u64 + 5;
let mut short = ShortReader::new(data, false);
assert_eq!(walk_all(&mut short, start), expected[start as usize..]);
}
#[test]
fn test_walk_index_file_ignores_trailing_partial_entry() {
// A torn final entry is dropped without an error, as Go does on io.EOF.
let entries = many_entries();
let mut data = idx_bytes(&entries);
data.extend_from_slice(&[0xAB; NEEDLE_MAP_ENTRY_SIZE - 1]);
let expected = walk_all(&mut Cursor::new(idx_bytes(&entries)), 0);
assert_eq!(walk_all(&mut Cursor::new(data.clone()), 0), expected);
let mut short = ShortReader::new(data, false);
assert_eq!(walk_all(&mut short, 0), expected);
}
#[test]
fn test_check_index_file_clean() {
let data = idx_bytes(&[
+18 -84
View File
@@ -36,11 +36,23 @@ pub(crate) fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> io::Res
}
#[cfg(windows)]
{
if read_full_at(file, buf, offset)? < buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF in seek_read",
));
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)))]
@@ -72,34 +84,9 @@ pub(crate) fn read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<us
}
}
/// Reads into `buf` at `offset` until it is full or the file ends, retrying
/// interrupted reads; returns how many bytes were read.
///
/// Unlike [`read_at`], a count below `buf.len()` always means end of file.
pub(crate) fn read_full_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<usize> {
fill_at(|b, at| read_at(file, b, at), buf, offset)
}
fn fill_at(
mut read: impl FnMut(&mut [u8], u64) -> io::Result<usize>,
buf: &mut [u8],
offset: u64,
) -> io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match read(&mut buf[filled..], offset + filled as u64) {
Ok(0) => break,
Ok(n) => filled += n,
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
Err(err) => return Err(err),
}
}
Ok(filled)
}
#[cfg(test)]
mod tests {
use super::{fill_at, read_at, read_exact_at, read_full_at};
use super::{read_at, read_exact_at};
use std::io::{ErrorKind, Write};
fn temp_file(bytes: &[u8]) -> tempfile::NamedTempFile {
@@ -152,57 +139,4 @@ mod tests {
let n = read_at(f.as_file(), &mut buf, 10).expect("read");
assert_eq!(n, 0);
}
/// A source that returns at most `chunk` bytes per call and fails with
/// `Interrupted` on its first call, like a network mount under a signal.
fn chunked(src: &[u8], chunk: usize) -> impl FnMut(&mut [u8], u64) -> std::io::Result<usize> {
let mut interrupted = false;
move |buf, at| {
if !interrupted {
interrupted = true;
return Err(ErrorKind::Interrupted.into());
}
let at = (at as usize).min(src.len());
let n = buf.len().min(chunk).min(src.len() - at);
buf[..n].copy_from_slice(&src[at..at + n]);
Ok(n)
}
}
#[test]
fn fill_at_fills_across_short_and_interrupted_reads() {
let src: Vec<u8> = (0..=255).collect();
let mut buf = [0u8; 100];
let n = fill_at(chunked(&src, 7), &mut buf, 50).expect("read");
assert_eq!(n, buf.len());
assert_eq!(&buf[..], &src[50..150]);
}
#[test]
fn fill_at_stops_at_end_of_source() {
let src: Vec<u8> = (0..=255).collect();
let mut buf = [0u8; 100];
let n = fill_at(chunked(&src, 7), &mut buf, 200).expect("read");
assert_eq!(n, 56);
assert_eq!(&buf[..n], &src[200..]);
}
#[test]
fn fill_at_propagates_other_errors() {
let mut buf = [0u8; 8];
let err = fill_at(|_, _| Err(ErrorKind::PermissionDenied.into()), &mut buf, 0)
.expect_err("error");
assert_eq!(err.kind(), ErrorKind::PermissionDenied);
}
#[test]
fn read_full_at_returns_the_short_count_only_at_eof() {
let f = temp_file(b"0123456789");
let mut buf = [0u8; 8];
assert_eq!(read_full_at(f.as_file(), &mut buf, 0).expect("read"), 8);
assert_eq!(&buf, b"01234567");
assert_eq!(read_full_at(f.as_file(), &mut buf, 6).expect("read"), 4);
assert_eq!(&buf[..4], b"6789");
assert_eq!(read_full_at(f.as_file(), &mut buf, 10).expect("read"), 0);
}
}
+10 -162
View File
@@ -3,7 +3,7 @@
use std::io;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
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;
@@ -34,30 +34,10 @@ pub(crate) fn is_storage_io_error(e: &io::Error) -> bool {
#[derive(Default)]
pub(crate) struct IoErrorTracker {
last: Mutex<Option<String>>,
/// The consecutive error count in the low 32 bits and, in the high 32,
/// how many times it has been cleared. They share one word so that
/// `record_success_at` updates both in one step: reads record their
/// outcomes here without the volume's write lock.
streak: AtomicU64,
count: AtomicI32,
quarantined: AtomicBool,
}
const STREAK_COUNT_BITS: u64 = 0xffff_ffff;
fn streak_count(streak: u64) -> i32 {
(streak & STREAK_COUNT_BITS) as i32
}
/// `streak` with its count cleared and one more clear on record.
fn streak_cleared(streak: u64) -> u64 {
(streak >> 32).wrapping_add(1) << 32
}
/// A point in the error streak, taken where a write landed whose success
/// is only recorded later. See `IoErrorTracker::record_success_at`.
#[derive(Clone, Copy)]
pub(crate) struct StreakMark(u64);
impl IoErrorTracker {
/// `Some(e)` records a failure, `None` a success. Only storage-media
/// failures count; every other outcome clears the count and last error.
@@ -65,22 +45,14 @@ impl IoErrorTracker {
if let Some(e) = err
&& is_storage_io_error(e)
{
self.streak.fetch_add(1, Ordering::Relaxed);
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.clear_count();
self.clear_last();
}
fn clear_count(&self) {
self.update_streak(|streak| Some(streak_cleared(streak)));
}
fn clear_last(&self) {
self.count.store(0, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock()
&& guard.is_some()
{
@@ -88,58 +60,17 @@ impl IoErrorTracker {
}
}
/// Apply `f` to the streak atomically; `None` leaves it as it is.
/// Returns the streak `f` produced, if any.
fn update_streak(&self, mut f: impl FnMut(u64) -> Option<u64>) -> Option<u64> {
let mut updated = None;
let _ = self
.streak
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |streak| {
updated = f(streak);
updated
});
updated
}
pub(crate) fn mark(&self) -> StreakMark {
StreakMark(self.streak.load(Ordering::Relaxed))
}
/// Record a success as if it had come at `mark`: the errors counted
/// before the mark are cleared and the ones counted since still stand,
/// as they would had each outcome been recorded in order. A streak
/// cleared since the mark is left as it is.
pub(crate) fn record_success_at(&self, mark: StreakMark) {
let before = streak_count(mark.0);
let updated = self.update_streak(|streak| {
if streak >> 32 != mark.0 >> 32 {
return None;
}
if streak_count(streak) <= before {
return Some(streak_cleared(streak));
}
Some(streak - before as u64)
});
// The last error stays when one counted since the mark is left.
if updated.is_some_and(|streak| streak_count(streak) == 0) {
self.clear_last();
}
}
/// 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();
let count = self.count.load(Ordering::Relaxed);
let quarantined = self.quarantined.load(Ordering::Relaxed);
(err, count, quarantined)
}
fn count(&self) -> i32 {
streak_count(self.streak.load(Ordering::Relaxed))
}
pub(crate) fn should_quarantine(&self) -> bool {
self.quarantined.load(Ordering::Relaxed) || self.count() >= IO_ERROR_TOLERANCE
self.quarantined.load(Ordering::Relaxed)
|| self.count.load(Ordering::Relaxed) >= IO_ERROR_TOLERANCE
}
pub(crate) fn mark_io_quarantined(&self) {
@@ -147,7 +78,7 @@ impl IoErrorTracker {
}
pub(crate) fn reset_io_error_state(&self) {
self.clear_count();
self.count.store(0, Ordering::Relaxed);
self.quarantined.store(false, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock() {
*guard = None;
@@ -160,11 +91,9 @@ impl IoErrorTracker {
*guard = err.map(|value| value.to_string());
}
if err.is_some() {
self.update_streak(|streak| {
Some((streak & !STREAK_COUNT_BITS) | IO_ERROR_TOLERANCE as u64)
});
self.count.store(IO_ERROR_TOLERANCE, Ordering::Relaxed);
} else {
self.clear_count();
self.count.store(0, Ordering::Relaxed);
}
}
}
@@ -242,87 +171,6 @@ mod tests {
assert!(tracker.should_quarantine());
}
#[test]
fn success_at_a_mark_keeps_only_the_errors_after_it() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
tracker.check_read_write_error(Some(&media_error()));
tracker.record_success_at(mark);
assert_eq!(
tracker.get_io_error_state(),
(Some(media_error().to_string()), 1, false)
);
}
#[test]
fn success_at_a_mark_with_nothing_after_it_clears_the_streak() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
tracker.record_success_at(mark);
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
#[test]
fn success_at_a_mark_leaves_a_streak_cleared_since() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
tracker.check_read_write_error(None);
tracker.check_read_write_error(Some(&media_error()));
tracker.record_success_at(mark);
assert_eq!(
tracker.get_io_error_state(),
(Some(media_error().to_string()), 1, false)
);
}
/// Reads update the tracker without the volume's write lock, so a
/// success replayed at a mark must not lose the errors they record
/// while it runs.
#[test]
fn success_at_a_mark_keeps_concurrent_errors() {
use std::sync::{Arc, Barrier};
const READERS: i32 = 4;
const ERRORS: i32 = 200;
for _ in 0..500 {
let tracker = Arc::new(IoErrorTracker::default());
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
let start = Arc::new(Barrier::new(READERS as usize + 1));
let readers: Vec<_> = (0..READERS)
.map(|_| {
let (tracker, start) = (tracker.clone(), start.clone());
std::thread::spawn(move || {
start.wait();
for _ in 0..ERRORS {
tracker.check_read_write_error(Some(&media_error()));
}
})
})
.collect();
start.wait();
while tracker.get_io_error_state().1 < 2 + READERS * ERRORS / 2 {
std::hint::spin_loop();
}
tracker.record_success_at(mark);
for reader in readers {
reader.join().unwrap();
}
// The two errors before the mark are cleared; every error the
// readers recorded after it stands.
assert_eq!(tracker.get_io_error_state().1, READERS * ERRORS);
}
}
#[test]
fn reset_io_error_state_lifts_the_quarantine() {
let tracker = IoErrorTracker::default();
-14
View File
@@ -247,8 +247,6 @@ impl CompactNeedleMap {
pub fn load_from_idx<R: Read + Seek>(reader: &mut R, version: Version) -> io::Result<Self> {
let mut nm = CompactNeedleMap::new();
idx::walk_index_file(reader, 0, |key, offset, size| {
// A read-only load attaches no writer, so this is its only size.
nm.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
nm.metric.maybe_set_max_needle_end(offset, size, version);
if offset.is_zero() || size.is_deleted() {
nm.delete_from_map(key);
@@ -1388,18 +1386,6 @@ impl NeedleMap {
}
}
/// Skew the live file count away from what the `.idx` holds, so tests
/// can build a volume whose reported count disagrees with a reload.
#[cfg(test)]
pub(crate) fn add_file_count_for_test(&self, delta: i64) {
let metric = match self {
NeedleMap::InMemory(nm) => &nm.metric,
NeedleMap::Redb(nm) => &nm.metric,
NeedleMap::SortedFile(_) => panic!("sorted-file needle maps are read-only"),
};
metric.file_count.fetch_add(delta, Ordering::Relaxed);
}
/// Largest (offset + actual size) seen during the load walk; 0 if the
/// map is empty. Used at volume load to detect .idx entries that
/// reference past the end of .dat (issue #8928) without a second scan.
File diff suppressed because it is too large Load Diff
@@ -131,12 +131,6 @@ impl Store {
let Some(base) = name.strip_suffix(".ecx") else {
continue;
};
// A 0-byte .ecx is a corrupt stub from a failed copy, not a
// credible owner — skip it so the scan keeps looking for a
// real index on a sibling disk (Go's indexEcxOwners).
if !ent.metadata().is_ok_and(|m| m.len() > 0) {
continue;
}
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
@@ -423,33 +417,4 @@ mod tests {
let post = fs::read(dir0.join(format!("{}_{}.ecx", collection, vid))).unwrap();
assert_eq!(post, ecx_local, "mirror overwrote dir0's existing .ecx");
}
/// The mirror shares Go's indexEcxOwners, which skips a 0-byte `.ecx`:
/// a stub must not be chosen as the source to mirror from.
#[test]
fn mirror_owner_index_skips_zero_byte_ecx() {
let tmp = TempDir::new().unwrap();
let dir0 = tmp.path().join("data0");
let dir1 = tmp.path().join("data1");
fs::create_dir_all(&dir0).unwrap();
fs::create_dir_all(&dir1).unwrap();
let collection = "video-recordings";
let vid = 4123u32;
plant_ecx(&dir0, collection, vid, b"");
plant_ecx(&dir1, collection, vid, &[0xA1u8; 20]);
let mut store = Store::new(NeedleMapKind::InMemory);
add_loc(&mut store, &dir0);
add_loc(&mut store, &dir1);
let owners = store.index_ecx_owners_for_mirror();
let owner = owners
.get(&EcKey {
collection: collection.to_string(),
vid: VolumeId(vid),
})
.expect("the valid .ecx on disk 1 must be indexed");
assert_eq!(owner.location, 1);
}
}
@@ -37,7 +37,6 @@ pub(crate) struct EcVolumeMissingIndex {
pub data_dir: String,
}
#[cfg(test)]
pub(crate) fn ec_local_ecx_path(dir: &str, collection: &str, vid: VolumeId) -> String {
if collection.is_empty() {
format!("{}/{}.ecx", dir, vid.0)
@@ -118,9 +117,10 @@ impl Store {
);
continue;
};
// A 0-byte local stub is not a mirrored index (Go gates this fast
// path on HasEcxFileOnDisk); mount against the owner instead.
let use_local_idx = loc.has_ecx_file_on_disk(&key.collection, key.vid);
let local_ecx = ec_local_ecx_path(&loc.idx_directory, &key.collection, key.vid);
let local_ecx_in_data = ec_local_ecx_path(&loc.directory, &key.collection, key.vid);
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
{
@@ -422,12 +422,6 @@ impl Store {
let Some(base) = name.strip_suffix(".ecx") else {
continue;
};
// A 0-byte .ecx is a corrupt stub from a failed copy, not a
// credible owner — skip it so the scan keeps looking for a
// real index on a sibling disk (Go's indexEcxOwners).
if !ent.metadata().is_ok_and(|m| m.len() > 0) {
continue;
}
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
@@ -645,34 +639,6 @@ mod tests {
.unwrap();
}
/// A 0-byte `.ecx` is not a credible owner (Go's indexEcxOwners skips
/// it): picking the stub would hide the valid index on the sibling disk.
#[test]
fn test_index_ecx_owners_skips_zero_byte_stub() {
let (store, _tmp) = make_test_store(2, None);
let d0 = store.locations[0].directory.clone();
let d1 = store.locations[1].directory.clone();
std::fs::write(ec_local_ecx_path(&d0, "pics", VolumeId(7)), b"").unwrap();
write_index_files(&d1, "pics", 7, 10, 4);
let owners = store.index_ecx_owners();
let owner = owners
.get(&EcKey {
collection: "pics".to_string(),
vid: VolumeId(7),
})
.expect("the valid .ecx on disk 1 must be indexed");
assert_eq!(owner.location, 1);
assert_eq!(owner.idx_dir, d1);
// A stub with no real index anywhere owns nothing.
std::fs::write(ec_local_ecx_path(&d0, "pics", VolumeId(8)), b"").unwrap();
assert!(!store.index_ecx_owners().contains_key(&EcKey {
collection: "pics".to_string(),
vid: VolumeId(8),
}));
}
/// An empty `.dat` (<= a superblock, i.e. zero needles) for an EC volume
/// is a leftover stub from the pre-fix loader. It must be swept on startup,
/// not loaded as a phantom empty volume. With the same vid's stub on two
@@ -1005,56 +971,6 @@ mod tests {
);
}
/// dir0 holds orphan shards next to a 0-byte `.ecx` stub from a failed
/// copy; the real index is on dir1. The stub must not count as a
/// locally-mirrored index (Go gates that fast path on HasEcxFileOnDisk),
/// or the shards get registered against an empty index.
#[test]
fn test_reconcile_ignores_zero_byte_local_ecx_stub() {
let tmp = TempDir::new().unwrap();
let dir0 = tmp.path().join("data0");
let dir1 = tmp.path().join("data1");
std::fs::create_dir_all(&dir0).unwrap();
std::fs::create_dir_all(&dir1).unwrap();
let collection = "grafana-loki";
let vid = 1094u32;
write_shard(dir0.to_str().unwrap(), collection, vid, 0);
write_shard(dir1.to_str().unwrap(), collection, vid, 1);
write_index_files(dir1.to_str().unwrap(), collection, vid, 10, 4);
let mut store = Store::new(NeedleMapKind::InMemory);
for dir in [&dir0, &dir1] {
store
.add_location(
dir.to_str().unwrap(),
dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
// Plant the stub after the startup scan so only the reconcile decision
// is under test, then drop dir0's mount and reconcile again.
store.locations[0].remove_ec_volume(VolumeId(vid));
std::fs::write(
ec_local_ecx_path(dir0.to_str().unwrap(), collection, VolumeId(vid)),
b"",
)
.unwrap();
store.reconcile_ec_shards_across_disks();
let ev0 = store.locations[0]
.find_ec_volume(VolumeId(vid))
.expect("dir0's shard must be mounted against the owner's index");
assert!(ev0.has_shard(0));
assert_eq!(ev0.ecx_actual_dir(), dir1.to_str().unwrap());
}
/// PR 9244 review case: idx_directory is configured but the
/// owner's .ecx / .ecj / .vif live in the owner's data dir
/// (the legacy "written before -dir.idx was set" layout). The
@@ -1476,7 +1392,7 @@ mod tests {
let vid = VolumeId(7004);
let collection = "grafana-loki";
store.delete_ec_shards(vid, collection, &[1]).unwrap();
store.delete_ec_shards(vid, collection, &[1]);
// Shard 1 file is gone on disk 1.
let p1 = format!(
File diff suppressed because it is too large Load Diff
-167
View File
@@ -477,74 +477,6 @@ async fn delete_then_get_returns_404() {
);
}
// Go answers both with 500 and an error containing "volume N is read only"
// (the write behind "failed to write to local disk: ").
#[tokio::test]
async fn write_and_delete_on_read_only_volume_say_is_read_only() {
let (state, _tmp) = test_state();
let uri = "/1,01637037d6";
let request = |method: &str, body: &[u8]| {
Request::builder()
.method(method)
.uri(uri)
.body(Body::from(body.to_vec()))
.unwrap()
};
let error_of = |body: Vec<u8>| -> String {
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
json["error"].as_str().unwrap_or_default().to_string()
};
let response = build_admin_router(state.clone())
.oneshot(request("POST", b"written before read-only"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
state
.store
.write()
.unwrap()
.find_volume_mut(VolumeId(1))
.unwrap()
.1
.set_no_write_or_delete(true);
let response = build_admin_router(state.clone())
.oneshot(request("POST", b"refused"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let error = error_of(body_bytes(response).await);
assert!(error.contains("volume 1 is read only"), "{error}");
let response = build_admin_router(state.clone())
.oneshot(request("DELETE", b""))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
error_of(body_bytes(response).await),
"Deletion Failed: volume 1 is read only"
);
// The needle is still there to delete once the volume is writable again.
state
.store
.write()
.unwrap()
.find_volume_mut(VolumeId(1))
.unwrap()
.1
.set_no_write_or_delete(false);
let response = build_admin_router(state.clone())
.oneshot(request("DELETE", b""))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
// ============================================================================
// 6. HEAD returns headers without body
// ============================================================================
@@ -1323,102 +1255,3 @@ async fn non_ascii_fid_and_ttl_are_rejected_not_panicked() {
"a non-ASCII ttl must behave like any other invalid ttl, not panic"
);
}
// ============================================================================
// The write queue answers an upload with the needle's real ETag
//
// The queue worker computes the CRC on the needle it was handed, so the handler
// has to know the checksum before it submits. Without that every queued upload
// came back as "00000000". The direct path is the reference: same payload, same
// ETag, for a plain body and for one the handler gzips before storing.
// ============================================================================
#[tokio::test]
async fn write_queue_upload_returns_same_etag_as_direct_write() {
use seaweed_volume::server::write_queue::WriteQueue;
let (direct_state, _direct_tmp) = test_state();
let (queued_state, _queued_tmp) = test_state();
let wq = WriteQueue::new(queued_state.clone(), 128);
let _ = queued_state.write_queue.set(wq);
let compressible = "seaweedfs ".repeat(200).into_bytes();
let uploads: [(&str, &[u8]); 2] = [
("/1,01637037d6", b"hello, seaweedfs!"),
("/1/02637037d6/notes.txt", &compressible),
];
for (uri, payload) in uploads {
let mut etags = Vec::new();
for state in [&direct_state, &queued_state] {
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.body(Body::from(payload.to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let header = response
.headers()
.get("ETag")
.expect("upload response has no ETag")
.to_str()
.unwrap()
.to_string();
let body = body_bytes(response).await;
let json: serde_json::Value =
serde_json::from_slice(&body).expect("POST response is not valid JSON");
let etag = json["eTag"].as_str().unwrap().to_string();
assert_eq!(header, format!("\"{}\"", etag));
etags.push(etag);
}
assert_ne!(etags[0], "00000000", "{}: direct ETag is the zero CRC", uri);
assert_eq!(
etags[1], etags[0],
"{}: queued upload must return the direct path's ETag",
uri
);
}
// The second upload really was stored gzipped, so its ETag is the CRC of
// the compressed bytes on both paths.
let app = build_admin_router(queued_state.clone());
let response = app
.oneshot(
Request::builder()
.uri(uploads[1].0)
.header("Accept-Encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["Content-Encoding"], "gzip");
// Re-uploading the same bytes is the unchanged path: 204 with the same ETag.
let (uri, payload) = uploads[0];
let mut etags = Vec::new();
for state in [&direct_state, &queued_state] {
let app = build_admin_router(state.clone());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.body(Body::from(payload.to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
etags.push(response.headers()["ETag"].to_str().unwrap().to_string());
}
assert_eq!(etags[1], etags[0], "unchanged upload ETag differs");
}
+1 -1
View File
@@ -21,7 +21,7 @@
<scala.binary.version>2.12</scala.binary.version>
<junit.version>4.13.2</junit.version>
<seaweedfs.hadoop3.client.version>4.00</seaweedfs.hadoop3.client.version>
<jackson.version>2.22.3</jackson.version> <!-- Upgraded from 2.15.3 -->
<jackson.version>2.22.1</jackson.version> <!-- Upgraded from 2.15.3 -->
<!-- jackson-annotations dropped the patch digit in 2.20; matches jackson.version's major.minor -->
<jackson.annotations.version>2.22</jackson.annotations.version>
<netty.version>4.2.16.Final</netty.version> <!-- Upgraded to 4.1.125.Final for security fixes (CVE in netty-codec < 4.1.125.Final, netty-codec-http2 <= 4.1.123.Final) -->
-258
View File
@@ -1,258 +0,0 @@
package iam
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"encoding/xml"
"io"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
webIdentityAudience = "web-identity-api-test"
webIdentitySubject = "spiffe://example.org/ns/app/sa/app"
)
// webIdentityIssuer is an OIDC issuer served by the test: discovery document
// and JWKS for one RSA key, so the server under test fetches real keys.
type webIdentityIssuer struct {
server *httptest.Server
key *rsa.PrivateKey
}
func newWebIdentityIssuer(t *testing.T) *webIdentityIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
issuer := &webIdentityIssuer{key: key}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer.server.URL,
"jwks_uri": issuer.server.URL + "/keys",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) {
b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
"kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256",
"n": b64(key.PublicKey.N.Bytes()), "e": b64(big.NewInt(int64(key.PublicKey.E)).Bytes()),
}}})
})
issuer.server = httptest.NewServer(mux)
t.Cleanup(issuer.server.Close)
return issuer
}
func (i *webIdentityIssuer) claims(sub string) jwt.MapClaims {
now := time.Now()
return jwt.MapClaims{"iss": i.server.URL, "sub": sub, "aud": webIdentityAudience,
"iat": now.Unix(), "exp": now.Add(10 * time.Minute).Unix()}
}
func (i *webIdentityIssuer) token(t *testing.T, sub string, key *rsa.PrivateKey) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, i.claims(sub))
tok.Header["kid"] = "k1"
signed, err := tok.SignedString(key)
require.NoError(t, err)
return signed
}
func (i *webIdentityIssuer) tokenForAudience(t *testing.T, sub, aud string) string {
t.Helper()
claims := i.claims(sub)
claims["aud"] = aud
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = "k1"
signed, err := tok.SignedString(i.key)
require.NoError(t, err)
return signed
}
func (i *webIdentityIssuer) unsignedToken(t *testing.T, sub string) string {
t.Helper()
signed, err := jwt.NewWithClaims(jwt.SigningMethodNone, i.claims(sub)).SignedString(jwt.UnsafeAllowNoneSignatureType)
require.NoError(t, err)
return signed
}
func trustPolicyFor(issuerURL, sub string) string {
doc, _ := json.Marshal(map[string]any{"Version": "2012-10-17", "Statement": []any{map[string]any{
"Effect": "Allow", "Principal": map[string]any{"Federated": issuerURL},
"Action": []string{"sts:AssumeRoleWithWebIdentity"},
"Condition": map[string]any{"StringEquals": map[string]any{"oidc:sub": sub}},
}}})
return string(doc)
}
// assumeWithWebIdentity returns session credentials, or nil and the error body.
func assumeWithWebIdentity(t *testing.T, roleArn, token string) (*credentials.Credentials, string) {
t.Helper()
resp, err := callSTSAPI(t, url.Values{
"Action": {"AssumeRoleWithWebIdentity"}, "Version": {"2011-06-15"},
"RoleArn": {roleArn}, "RoleSessionName": {"web-identity-api"}, "WebIdentityToken": {token},
})
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if resp.StatusCode != http.StatusOK {
return nil, string(body)
}
var out AssumeRoleWithWebIdentityTestResponse
require.NoError(t, xml.Unmarshal(body, &out), "body: %s", body)
c := out.Result.Credentials
return credentials.NewStaticCredentials(c.AccessKeyId, c.SecretAccessKey, c.SessionToken), ""
}
func s3ClientWith(t *testing.T, creds *credentials.Credentials) *s3.S3 {
t.Helper()
sess, err := session.NewSession(&aws.Config{
Region: aws.String(TestRegion), Endpoint: aws.String(TestS3Endpoint),
Credentials: creds, S3ForcePathStyle: aws.Bool(true), DisableSSL: aws.Bool(true),
})
require.NoError(t, err)
return s3.New(sess)
}
// TestWebIdentityWithProviderAndRoleManagedThroughIAMAPI configures STS
// federation entirely at runtime — an OIDC provider, a managed policy and a role
// created through the IAM API, with no static configuration — and checks that
// the role admits exactly the subject its trust policy names, with exactly the
// permissions of its attached policy.
func TestWebIdentityWithProviderAndRoleManagedThroughIAMAPI(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !isSTSEndpointRunning(t) {
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first")
}
framework := NewS3IAMTestFramework(t)
defer framework.Cleanup()
admin, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
issuer := newWebIdentityIssuer(t)
bucket := framework.GenerateUniqueBucketName("web-identity")
require.NoError(t, framework.CreateBucketWithCleanup(adminS3, bucket))
provider, err := admin.CreateOpenIDConnectProvider(&iam.CreateOpenIDConnectProviderInput{
Url: aws.String(issuer.server.URL),
ClientIDList: []*string{aws.String(webIdentityAudience)},
// Required by this SDK version; pinning only applies to a TLS issuer,
// and the test issuer is plain HTTP.
ThumbprintList: []*string{aws.String("0000000000000000000000000000000000000000")},
})
require.NoError(t, err)
defer admin.DeleteOpenIDConnectProvider(&iam.DeleteOpenIDConnectProviderInput{OpenIDConnectProviderArn: provider.OpenIDConnectProviderArn})
policyDoc, _ := json.Marshal(map[string]any{"Version": "2012-10-17", "Statement": []any{map[string]any{
"Effect": "Allow", "Action": []string{"s3:*"},
"Resource": []string{"arn:aws:s3:::" + bucket, "arn:aws:s3:::" + bucket + "/*"},
}}})
policy, err := admin.CreatePolicy(&iam.CreatePolicyInput{
PolicyName: aws.String(bucket + "-rw"), PolicyDocument: aws.String(string(policyDoc)),
})
require.NoError(t, err)
defer admin.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policy.Policy.Arn})
// Role names are at most 64 characters; the bucket name is longer.
roleName := "web-identity-" + strconv.FormatInt(time.Now().UnixNano(), 36)
role, err := admin.CreateRole(&iam.CreateRoleInput{
RoleName: aws.String(roleName),
AssumeRolePolicyDocument: aws.String(trustPolicyFor(issuer.server.URL, webIdentitySubject)),
})
require.NoError(t, err)
defer admin.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
_, err = admin.AttachRolePolicy(&iam.AttachRolePolicyInput{RoleName: aws.String(roleName), PolicyArn: policy.Policy.Arn})
require.NoError(t, err)
defer admin.DetachRolePolicy(&iam.DetachRolePolicyInput{RoleName: aws.String(roleName), PolicyArn: policy.Policy.Arn})
roleArn := aws.StringValue(role.Role.Arn)
t.Run("the trusted subject gets credentials scoped to the attached policy", func(t *testing.T) {
creds, failure := assumeWithWebIdentity(t, roleArn, issuer.token(t, webIdentitySubject, issuer.key))
require.NotNil(t, creds, "AssumeRoleWithWebIdentity refused the trusted subject: %s", failure)
client := s3ClientWith(t, creds)
_, err := client.PutObject(&s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String("federated.txt")})
assert.NoError(t, err, "the session cannot write the bucket its policy grants")
_, err = client.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket + "-other")})
assert.Error(t, err, "the session created a bucket its policy does not grant")
})
refusals := map[string]string{
"another subject": issuer.token(t, "spiffe://example.org/ns/other/sa/other", issuer.key),
"a token signed by another key": func() string {
other, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return issuer.token(t, webIdentitySubject, other)
}(),
"an unsigned token": issuer.unsignedToken(t, webIdentitySubject),
// The provider accepts only its registered client IDs as audiences.
"a token issued for another audience": issuer.tokenForAudience(t, webIdentitySubject, "some-other-service"),
}
for name, token := range refusals {
t.Run("refuses "+name, func(t *testing.T) {
creds, _ := assumeWithWebIdentity(t, roleArn, token)
assert.Nil(t, creds, "AssumeRoleWithWebIdentity issued credentials for %s", name)
})
}
t.Run("an updated trust policy takes effect", func(t *testing.T) {
moved := "spiffe://example.org/ns/app/sa/moved"
_, err := admin.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{
RoleName: aws.String(roleName), PolicyDocument: aws.String(trustPolicyFor(issuer.server.URL, moved)),
})
require.NoError(t, err)
creds, _ := assumeWithWebIdentity(t, roleArn, issuer.token(t, webIdentitySubject, issuer.key))
assert.Nil(t, creds, "the subject removed from the trust policy still assumes the role")
creds, failure := assumeWithWebIdentity(t, roleArn, issuer.token(t, moved, issuer.key))
assert.NotNil(t, creds, "the subject added to the trust policy was refused: %s", failure)
})
t.Run("a session does not survive its role being deleted and created again", func(t *testing.T) {
trust := trustPolicyFor(issuer.server.URL, webIdentitySubject)
_, err := admin.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{RoleName: aws.String(roleName), PolicyDocument: aws.String(trust)})
require.NoError(t, err)
before, failure := assumeWithWebIdentity(t, roleArn, issuer.token(t, webIdentitySubject, issuer.key))
require.NotNil(t, before, "precondition: %s", failure)
_, err = admin.DetachRolePolicy(&iam.DetachRolePolicyInput{RoleName: aws.String(roleName), PolicyArn: policy.Policy.Arn})
require.NoError(t, err)
_, err = admin.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
require.NoError(t, err)
recreated, err := admin.CreateRole(&iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)})
require.NoError(t, err)
_, err = admin.AttachRolePolicy(&iam.AttachRolePolicyInput{RoleName: aws.String(roleName), PolicyArn: policy.Policy.Arn})
require.NoError(t, err)
assert.NotEqual(t, aws.StringValue(role.Role.RoleId), aws.StringValue(recreated.Role.RoleId), "the recreated role reuses the deleted role's ID")
_, err = s3ClientWith(t, before).PutObject(&s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String("revived.txt")})
assert.Error(t, err, "a session of the deleted role works again under the new role of the same name")
after, failure := assumeWithWebIdentity(t, roleArn, issuer.token(t, webIdentitySubject, issuer.key))
require.NotNil(t, after, "the new role refuses its trusted subject: %s", failure)
_, err = s3ClientWith(t, after).PutObject(&s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String("fresh.txt")})
assert.NoError(t, err, "a session of the new role cannot use its policy")
})
}
@@ -1,231 +0,0 @@
package remote_cache
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
evictRemoteS3 = "http://localhost:28334"
evictRemoteMaster = "30334"
evictRemoteFiler = "28889"
evictRemoteVolume = "30341"
evictRemoteWebdav = "27334"
evictRemoteMetrics = "30326"
evictPrimaryS3 = "http://localhost:28333"
evictPrimaryMaster = "30333"
evictPrimaryFiler = "28887"
evictPrimaryVolume = "30340"
evictPrimaryWebdav = "27333"
evictPrimaryMetrics = "30327"
evictBucket = "evictsrc"
evictMount = "evictmnt"
)
var miniLogPaths []string
func startMini(t *testing.T, dir string, args ...string) {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0755))
logPath := filepath.Join(dir, "weed.log")
logFile, err := os.Create(logPath)
require.NoError(t, err)
miniLogPaths = append(miniLogPaths, logPath)
cmd := exec.Command(weedBinary, append([]string{"mini",
"-dir=" + dir,
"-s3.config=s3_config.json",
"-s3.allowDeleteBucketNotEmpty=true",
"-ip=127.0.0.1", "-ip.bind=127.0.0.1",
}, args...)...)
cmd.Stdout = logFile
cmd.Stderr = logFile
require.NoError(t, cmd.Start())
t.Cleanup(func() {
cmd.Process.Kill()
cmd.Wait()
logFile.Close()
})
}
func waitForHTTP(t *testing.T, url string) {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if resp, err := http.Get(url); err == nil {
resp.Body.Close()
return
}
time.Sleep(time.Second)
}
for _, p := range miniLogPaths {
if data, err := os.ReadFile(p); err == nil {
lines := strings.Split(string(data), "\n")
if len(lines) > 30 {
lines = lines[len(lines)-30:]
}
t.Logf("last lines of %s:\n%s", p, strings.Join(lines, "\n"))
}
}
t.Fatalf("timed out waiting for %s", url)
}
func shellOn(t *testing.T, masterPort, command string) string {
t.Helper()
cmd := exec.Command(weedBinary, "shell", "-master=localhost:"+masterPort)
cmd.Stdin = strings.NewReader(command + "\nexit\n")
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "shell %q failed: %s", command, out)
return stripLogs(string(out))
}
func chunkCountOn(t *testing.T, masterPort, path string) string {
meta := shellOn(t, masterPort, "fs.meta.cat "+path)
idx := strings.LastIndex(meta, "chunks ")
require.GreaterOrEqualf(t, idx, 0, "no chunk count in %s", meta)
return strings.Fields(meta[idx+len("chunks "):])[0]
}
func volumeStats(t *testing.T, volumePort string) (size, garbage uint64) {
t.Helper()
resp, err := http.Get("http://localhost:" + volumePort + "/status")
require.NoError(t, err)
defer resp.Body.Close()
var status struct {
Volumes []struct {
Size uint64 `json:"Size"`
DeletedByteCount uint64 `json:"DeletedByteCount"`
} `json:"Volumes"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&status))
for _, v := range status.Volumes {
size += v.Size
garbage += v.DeletedByteCount
}
return size, garbage
}
func readViaFiler(t *testing.T, filerPort, path string) []byte {
t.Helper()
resp, err := http.Get("http://localhost:" + filerPort + path)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "read %s", path)
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return data
}
// TestRemoteCacheEvictUnderPressure fills a cache constrained to two small
// volumes with remote-mounted objects until writes fail, then verifies the
// filer evicts the oldest synced entry, vacuums the garbage, and a later read
// caches again.
func TestRemoteCacheEvictUnderPressure(t *testing.T) {
if testing.Short() {
t.Skip("spawns two weed mini clusters")
}
if _, err := os.Stat(weedBinary); err != nil {
t.Skipf("weed binary not found at %s; run make build-weed", weedBinary)
}
if isServerRunning(evictRemoteS3) || isServerRunning(evictPrimaryS3) {
t.Skip("eviction test ports are already in use")
}
tmp := t.TempDir()
startMini(t, filepath.Join(tmp, "remote"),
"-s3.port=28334", "-master.port="+evictRemoteMaster,
"-filer.port="+evictRemoteFiler, "-volume.port="+evictRemoteVolume,
"-webdav.port="+evictRemoteWebdav, "-metricsPort="+evictRemoteMetrics)
waitForHTTP(t, evictRemoteS3)
startMini(t, filepath.Join(tmp, "primary"),
"-s3.port=28333", "-master.port="+evictPrimaryMaster,
"-filer.port="+evictPrimaryFiler, "-volume.port="+evictPrimaryVolume,
"-webdav.port="+evictPrimaryWebdav, "-metricsPort="+evictPrimaryMetrics,
"-volume.allowUntrustedRemoteEndpoints", "-filer.allowUntrustedRemoteEndpoints",
"-s3.allowUntrustedRemoteEndpoints",
"-master.volumeSizeLimitMB=32", "-volume.max=2",
"-filer.remoteCacheEvictThreshold=0.99")
waitForHTTP(t, evictPrimaryS3)
remote := createS3Client(evictRemoteS3)
_, err := remote.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(evictBucket)})
require.NoError(t, err)
for i := 0; i < 5; i++ {
data := make([]byte, 16*1024*1024)
for j := range data {
data[j] = byte(i + j%251)
}
_, err = remote.PutObject(&s3.PutObjectInput{
Bucket: aws.String(evictBucket),
Key: aws.String(fmt.Sprintf("obj%d.bin", i)),
Body: bytes.NewReader(data),
})
require.NoError(t, err)
}
shellOn(t, evictPrimaryMaster, fmt.Sprintf(
"remote.configure -name=evictremote -type=s3 -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=%s -s3.region=us-east-1",
accessKey, secretKey, evictRemoteS3))
shellOn(t, evictPrimaryMaster, fmt.Sprintf(
"remote.mount -dir=/buckets/%s -remote=evictremote/%s -nonempty", evictMount, evictBucket))
shellOn(t, evictPrimaryMaster, fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", evictMount))
time.Sleep(2 * time.Second)
mount := "/buckets/" + evictMount
// obj0 caches alone so it is the oldest evictable entry.
first := readViaFiler(t, evictPrimaryFiler, mount+"/obj0.bin")
require.Len(t, first, 16*1024*1024)
require.NotEqual(t, "0", chunkCountOn(t, evictPrimaryMaster, mount+"/obj0.bin"), "obj0 should be cached")
// 4 x 16MB against ~64MB of capacity: the fills overrun, hit the
// capacity error path, and trigger eviction + vacuum.
var wg sync.WaitGroup
for i := 1; i <= 4; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
data := readViaFiler(t, evictPrimaryFiler, fmt.Sprintf("%s/obj%d.bin", mount, i))
assert.Len(t, data, 16*1024*1024)
}(i)
}
wg.Wait()
// Eviction must have dropped obj0's local chunks and vacuumed the
// tombstoned bytes, leaving only live data within the two volumes.
require.True(t, waitForCondition(t, func() bool {
size, garbage := volumeStats(t, evictPrimaryVolume)
return garbage == 0 && size <= 72*1024*1024
}, 2*time.Minute, "volumes to be reclaimed by eviction+vacuum"),
"evicted chunks were not reclaimed")
assert.Equal(t, "0", chunkCountOn(t, evictPrimaryMaster, mount+"/obj0.bin"),
"oldest cached entry should be evicted back to remote-only")
// The cache self-heals: reading the evicted object re-caches it. Fills
// still running from the concurrent wave may evict it again, so retry
// the read until a commit sticks.
var again []byte
require.True(t, waitForCondition(t, func() bool {
again = readViaFiler(t, evictPrimaryFiler, mount+"/obj0.bin")
return chunkCountOn(t, evictPrimaryMaster, mount+"/obj0.bin") != "0"
}, 2*time.Minute, "obj0 to re-cache"),
"evicted object did not re-cache on read")
assert.Equal(t, first, again)
}
@@ -1,14 +0,0 @@
# Strict Iceberg reader used by the OLake integration test. PyIceberg is
# deliberate here: it rejects manifests that omit the spec's field ids and
# parquet without either field ids or a name mapping, so a passing read proves
# the catalog served something every engine can consume -- not just something
# OLake itself can read back.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "pyiceberg[s3fs]==0.11.1" "pyarrow==25.0.0"
COPY inspect_table.py /app/
ENTRYPOINT ["python3", "/app/inspect_table.py"]
-104
View File
@@ -1,104 +0,0 @@
# OLake Iceberg Catalog Integration Test
An integration test for [OLake](https://github.com/datazip-inc/olake) against
SeaweedFS's Iceberg REST Catalog, in the same shape as `catalog_clickhouse`.
## Why OLake, given we already test five engines
Two things here are covered by nothing else in this directory.
**It is a strict Java Iceberg client.** OLake does not write Iceberg from Go —
its Go process spawns a Java sidecar over gRPC and writes through the official
Apache Iceberg library, because the Go library has no equality deletes and CDC
needs them. So this test exercises the client class that
`weed/s3api/iceberg/metadata_compliance.go` exists to serve: the one
that fails with *"Cannot parse missing long current-snapshot-id"* when the
catalog omits spec-required keys that `iceberg-go` strips via `omitempty`.
**It produces equality deletes.** OLake is a CDC tool. Its upsert path commits
`operation=overwrite` with an equality-delete file, and a delete manifest
alongside the data manifests. ClickHouse, StarRocks, Doris and DuckDB all only
append.
## What it asserts
`TestOLakeIcebergCatalog` runs six subtests against a `weed mini` cluster with
a pre-created table bucket and a Postgres source:
| Subtest | What a failure means |
|---|---|
| `CheckDestination` | `olake check` did not reach `SUCCEEDED`, or it passed without ever loading `org.apache.iceberg.rest.RESTSessionCatalog` — the second case means the destination was never actually contacted. |
| `Discover` | OLake could not enumerate the source table, or wrote no `streams.json`. |
| `FullSyncAppendsRows` | The sync read fewer than the three seeded rows, or committed no Iceberg snapshot. |
| `StrictReaderSeesRows` | PyIceberg could not read back what the Java writer committed, or the values differ. This is the data path, not just metadata. |
| `UpsertProducesEqualityDelete` | After an `UPDATE` and a re-sync, no snapshot recorded an overwrite carrying equality deletes, or the current snapshot has no delete manifest. |
| `CompliantWriterNeedsNoRepair` | The catalog rewrote a manifest the official Iceberg Java writer produced. That is a regression in the repair gate, not a problem with OLake. |
## The one thing this test deliberately does not check
It does **not** assert that a reader sees the updated row and no duplicate.
PyIceberg refuses to scan a table carrying equality deletes
([apache/iceberg#6568](https://github.com/apache/iceberg/issues/6568)) while
reading its metadata perfectly well — so a rows-mode read after the upsert would
raise, not pass. The alternative is an engine that applies equality deletes,
which for StarRocks means a 3 GB image and 12 GB of RAM in CI.
The line drawn instead: **recording the commit correctly is the catalog's
contract; applying deletes on read is the query engine's.** The metadata
assertions cover our half.
This was verified once by hand outside CI, on 2026-09-23, with StarRocks 4.1.4
attached to the same catalog: after the upsert it read 3 rows / 3 distinct ids
with `id=1` showing the updated value and `_op_type=u`. If someone later wants
that inside the gate, add a reader that supports equality deletes — **do not**
"upgrade" this test to a PyIceberg rows-mode read after the upsert. It would not
pass; and if PyIceberg ever starts silently skipping deletes instead of raising,
it would pass by not looking.
## What the config proves
Nothing in the destination config is SeaweedFS-specific:
```json
{
"catalog_type": "rest",
"rest_catalog_url": "http://HOST:ICEBERG_PORT",
"iceberg_s3_path": "s3://olake-tables",
"s3_endpoint": "http://HOST:S3_PORT",
"rest_auth_type": "oauth2",
"oauth2_uri": "http://HOST:ICEBERG_PORT/v1/oauth/tokens",
"credential": "ACCESS_KEY:SECRET_KEY"
}
```
`catalog_type` is the generic `rest`, auth is the standard OAuth2
client-credentials flow, and `s3_path_style` does not even need setting —
OLake turns it on by itself whenever `s3_endpoint` is non-empty. As of
OLake v0.10.1 this works with no change on either side.
## Running it
```sh
go test ./test/s3tables/catalog_olake/ -run TestOLakeIcebergCatalog -v -timeout 25m
```
Needs Docker and a `weed` binary (at `weed/weed` under the repo root, or on
`PATH`). Takes about 35 seconds. It skips rather than fails when Docker is
absent, and `SEAWEEDFS_SKIP_OLAKE_TESTS=1` skips it outright.
Overrides: `OLAKE_IMAGE` (default `olakego/source-postgres:latest`),
`POSTGRES_IMAGE` (default `postgres:16`).
## In CI
Runs as `olake-iceberg-catalog-tests` in `.github/workflows/s3-tables-tests.yml`,
on a matrix of a pinned image plus `latest` — the same shape the ClickHouse job
uses, and for the same reason: OLake's Iceberg writer is a Java sidecar whose
library version moves independently of the Go release, so the `latest` leg is
what catches drift in the client rather than in OLake itself.
The job asserts the suite actually ran — at least one top-level `--- PASS` and
zero `--- SKIP` — rather than trusting a green exit. This suite skips itself
when Docker is unavailable, and a skipped suite reporting success is how a gate
quietly stops being one.
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""Inspect an Iceberg table written by OLake, through the SeaweedFS REST catalog.
Two modes, because a strict reader cannot do both:
rows -- scan the table and print "id,region,amount" per row, ordered by
id. Only valid while the table has no equality deletes.
snapshots -- print one line per snapshot with its operation and delete-file
counters, plus the manifest content kinds of the current
snapshot.
The split exists because PyIceberg refuses to scan a table carrying equality
deletes (apache/iceberg#6568) while reading its metadata perfectly well. OLake
is a CDC tool, so its upsert path produces exactly those deletes -- asserting
the commit landed is the catalog's concern, and applying deletes on read is the
query engine's.
"""
import argparse
import sys
from pyiceberg.catalog import load_catalog
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("mode", choices=["rows", "snapshots"])
p.add_argument("--catalog-url", required=True)
p.add_argument("--warehouse", required=True)
p.add_argument("--prefix", required=True)
p.add_argument("--s3-endpoint", required=True)
p.add_argument("--access-key", required=True)
p.add_argument("--secret-key", required=True)
p.add_argument("--region", default="us-east-1")
p.add_argument("--namespace", action="append", required=True)
p.add_argument("--table", required=True)
args = p.parse_args()
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"credential": f"{args.access_key}:{args.secret_key}",
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.endpoint": args.s3_endpoint,
"s3.region": args.region,
"s3.path-style-access": "true",
},
)
table = catalog.load_table(tuple(args.namespace) + (args.table,))
if args.mode == "rows":
data = table.scan().to_arrow().to_pydict()
# amount is decimal(10,2) in Postgres but arrives here as a float, whose
# repr drops trailing zeros (120.5, not 120.50). Format it to the source
# scale so the expected values in the Go test stay readable.
for row_id, region, amount in sorted(
zip(data["id"], data["region"], data["amount"])
):
print("%s,%s,%.2f" % (row_id, region, float(amount)))
return 0
print("format-version=%d" % table.metadata.format_version)
ids = table.metadata.schemas[-1].identifier_field_ids
print("identifier-field-ids=%s" % ",".join(str(i) for i in ids))
for snap in table.metadata.snapshots:
s = snap.summary
print(
"snapshot operation=%s total-delete-files=%s added-delete-files=%s "
"added-equality-deletes=%s total-records=%s"
% (
s.operation,
s.get("total-delete-files", "0"),
s.get("added-delete-files", "0"),
s.get("added-equality-deletes", "0"),
s.get("total-records", "0"),
)
)
current = table.current_snapshot()
kinds = [str(m.content).rsplit(".", 1)[-1] for m in current.manifests(table.io)]
print("current-manifest-kinds=%s" % ",".join(kinds))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,623 +0,0 @@
// Package catalog_olake provides an integration test for OLake
// (github.com/datazip-inc/olake) against the SeaweedFS Iceberg REST Catalog.
//
// OLake matters here for two reasons that no other engine in this directory
// covers. First, it does not write Iceberg from Go: it spawns a Java sidecar
// over gRPC and writes through the official Apache Iceberg library, so this is
// a strict Java client -- the class that iceberg/metadata_compliance.go exists
// to serve. Second, it is a CDC tool, so its upsert path emits equality
// deletes, which neither the ClickHouse nor the Doris test exercises.
package catalog_olake
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
olakeDefaultImage = "olakego/source-postgres:latest"
postgresDefaultImage = "postgres:16"
readerImage = "seaweedfs-olake-reader:test"
tableBucketName = "olake-tables"
sourceDatabase = "olakedb"
sourceUser = "olake"
sourcePassword = "olakepw"
// OLake derives the destination namespace from the source: it joins the
// connector name, database and schema. Asserting the derived name rather
// than configuring one keeps the test honest about what OLake actually
// does with our catalog.
expectedNamespace = "postgres_olakedb_public"
expectedTable = "orders"
postgresStartTimeout = 90 * time.Second
olakeRunTimeout = 6 * time.Minute
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
configDir string
bindIP string
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
filerPort int
filerGrpcPort int
s3Port int
s3GrpcPort int
icebergPort int
postgresPort int
accessKey string
secretKey string
weedProcess *exec.Cmd
weedCancel func()
postgresContainer string
}
func TestOLakeIcebergCatalog(t *testing.T) {
requireOLakeRuntime(t)
env := NewTestEnvironment(t)
defer env.Cleanup(t)
env.StartSeaweedFS(t)
env.startPostgres(t)
env.seedSource(t)
buildReaderImage(t)
env.writeOLakeConfigs(t)
t.Run("CheckDestination", func(t *testing.T) {
out := env.runOLake(t, "check",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json")
if !strings.Contains(out, `"status":"SUCCEEDED"`) {
t.Fatalf("olake check did not report SUCCEEDED.\n%s", tailLines(out, 40))
}
// The Java sidecar, not the Go process, is what talks to our catalog.
// If this line is missing the check passed without exercising the
// Iceberg REST path at all.
if !strings.Contains(out, "org.apache.iceberg.rest.RESTSessionCatalog") {
t.Errorf("check passed but never loaded the Iceberg REST catalog; "+
"the destination may not have been contacted.\n%s", tailLines(out, 40))
}
})
t.Run("Discover", func(t *testing.T) {
out := env.runOLake(t, "discover",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json")
if !strings.Contains(out, `"stream_name":"`+expectedTable+`"`) {
t.Fatalf("discover did not report the %s stream.\n%s", expectedTable, tailLines(out, 20))
}
if _, err := os.Stat(filepath.Join(env.configDir, "streams.json")); err != nil {
t.Fatalf("discover did not write streams.json: %v", err)
}
})
t.Run("FullSyncAppendsRows", func(t *testing.T) {
out := env.runOLake(t, "sync",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json",
"--streams", "/mnt/config/streams.json",
"--state", "/mnt/config/state.json")
if !strings.Contains(out, "Total records read: 3") {
t.Fatalf("sync did not read the three seeded rows.\n%s", tailLines(out, 30))
}
if !strings.Contains(out, "Committed snapshot") {
t.Fatalf("sync never committed an Iceberg snapshot.\n%s", tailLines(out, 30))
}
})
t.Run("StrictReaderSeesRows", func(t *testing.T) {
rows := env.readTable(t, "rows")
want := []string{"1,us-east,120.50", "2,us-west,87.20", "3,eu-west,210.00"}
got := nonEmptyLines(rows)
if len(got) != len(want) {
t.Fatalf("PyIceberg read %d rows, want %d.\n%s", len(got), len(want), rows)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("row %d = %q, want %q", i, got[i], want[i])
}
}
})
t.Run("UpsertProducesEqualityDelete", func(t *testing.T) {
// Push the row past the incremental cursor so the next sync re-emits it
// as an update rather than skipping it.
env.execSQL(t, "UPDATE orders SET amount=999.99, region='ap-south' WHERE id=1")
out := env.runOLake(t, "sync",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json",
"--streams", "/mnt/config/streams.json",
"--state", "/mnt/config/state.json")
if !strings.Contains(out, "delete files") {
t.Fatalf("re-sync committed no delete files.\n%s", tailLines(out, 30))
}
meta := env.readTable(t, "snapshots")
if !strings.Contains(meta, "format-version=2") {
t.Errorf("expected Iceberg format-version 2, got:\n%s", meta)
}
if !strings.Contains(meta, "identifier-field-ids=") ||
strings.Contains(meta, "identifier-field-ids=\n") {
t.Errorf("table carries no identifier fields, so OLake could not "+
"have upserted:\n%s", meta)
}
var sawOverwrite bool
for _, line := range nonEmptyLines(meta) {
if !strings.HasPrefix(line, "snapshot operation=") {
continue
}
if strings.Contains(line, "operation=Operation.OVERWRITE") &&
!strings.Contains(line, "added-equality-deletes=0") {
sawOverwrite = true
}
}
if !sawOverwrite {
t.Errorf("no snapshot recorded an overwrite carrying equality "+
"deletes:\n%s", meta)
}
if !strings.Contains(meta, "current-manifest-kinds=") ||
!strings.Contains(meta, "DELETES") {
t.Errorf("current snapshot has no delete manifest:\n%s", meta)
}
// Deliberately NOT asserted here: that a reader sees the updated value
// and no duplicate row. PyIceberg refuses to scan a table carrying
// equality deletes (apache/iceberg#6568), so a rows-mode read would
// fail rather than pass, and swapping in an engine that can apply them
// costs this test a multi-gigabyte image. Applying deletes on read is
// the engine's contract; recording the commit correctly is ours, and
// that is what the assertions above cover. See the README.
})
t.Run("CompliantWriterNeedsNoRepair", func(t *testing.T) {
names := env.listTableMetadata(t)
if len(names) == 0 {
t.Fatalf("no metadata files found for %s.%s", expectedNamespace, expectedTable)
}
for _, n := range names {
if strings.HasPrefix(n, "repaired-") {
t.Errorf("catalog repaired a manifest written by OLake (%s); "+
"the official Iceberg Java writer is expected to be "+
"spec-compliant, so this is a regression in the repair "+
"gate rather than in OLake", n)
}
}
})
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, err := os.Stat(weedBinary); err != nil || info.IsDir() {
weedBinary = filepath.Join(seaweedDir, "weed", "weed", "weed")
if info, err := os.Stat(weedBinary); err != nil || info.IsDir() {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
}
dataDir, err := os.MkdirTemp("", "seaweed-olake-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
configDir := filepath.Join(dataDir, "olake")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}
// 9 for the mini cluster, 1 for the Postgres source mapped on the host.
ports := testutil.MustAllocatePorts(t, 10)
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
configDir: configDir,
bindIP: testutil.FindBindIP(),
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
icebergPort: ports[8],
postgresPort: ports[9],
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
cmd := exec.Command(env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-s3.config", iamConfigPath,
// Pre-create the table bucket the way an operator would, rather than
// reaching for the S3 Tables control plane from the test.
"-tableBucket", tableBucketName,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
)
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
env.weedCancel = func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
url := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
if !waitForService(url, 45*time.Second) {
t.Fatalf("Iceberg REST API did not become ready at %s", url)
}
}
func (env *TestEnvironment) startPostgres(t *testing.T) {
t.Helper()
name := fmt.Sprintf("olake-pg-%d", env.postgresPort)
_ = exec.Command("docker", "rm", "-f", name).Run()
cmd := exec.Command("docker", "run", "-d", "--name", name,
"-e", "POSTGRES_USER="+sourceUser,
"-e", "POSTGRES_PASSWORD="+sourcePassword,
"-e", "POSTGRES_DB="+sourceDatabase,
"-p", fmt.Sprintf("%d:5432", env.postgresPort),
postgresImage(),
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to start Postgres: %v\n%s", err, out)
}
env.postgresContainer = name
deadline := time.Now().Add(postgresStartTimeout)
for time.Now().Before(deadline) {
probe := exec.Command("docker", "exec", name,
"pg_isready", "-U", sourceUser, "-d", sourceDatabase)
if err := probe.Run(); err == nil {
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Postgres did not become ready within %s", postgresStartTimeout)
}
func (env *TestEnvironment) seedSource(t *testing.T) {
t.Helper()
env.execSQL(t, `
CREATE TABLE orders (
id int PRIMARY KEY,
region text,
amount numeric(10,2),
order_ts timestamp
);
INSERT INTO orders VALUES
(1,'us-east',120.50,'2026-07-27 09:15'),
(2,'us-west', 87.20,'2026-07-27 09:20'),
(3,'eu-west',210.00,'2026-07-27 09:31');
ALTER TABLE orders REPLICA IDENTITY FULL;`)
}
func (env *TestEnvironment) execSQL(t *testing.T, sqlText string) {
t.Helper()
cmd := exec.Command("docker", "exec", env.postgresContainer,
"psql", "-v", "ON_ERROR_STOP=1", "-U", sourceUser, "-d", sourceDatabase,
"-c", sqlText)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("psql failed: %v\n%s", err, out)
}
}
// writeOLakeConfigs writes the source and destination configs. The destination
// is the point of this test: nothing in it is SeaweedFS-specific. catalog_type
// is the generic "rest", auth is the standard OAuth2 client-credentials flow,
// and path-style access is not even set here because OLake turns it on by
// itself whenever s3_endpoint is non-empty.
func (env *TestEnvironment) writeOLakeConfigs(t *testing.T) {
t.Helper()
source := map[string]any{
"host": env.bindIP,
"port": env.postgresPort,
"database": sourceDatabase,
"username": sourceUser,
"password": sourcePassword,
"jdbc_url_params": map[string]any{},
"ssl": map[string]any{"mode": "disable"},
"update_method": map[string]any{"type": "Standalone"},
"max_threads": 2,
"retry_count": 0,
}
catalogURL := fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort)
destination := map[string]any{
"type": "ICEBERG",
"writer": map[string]any{
"catalog_type": "rest",
"rest_catalog_url": catalogURL,
"catalog_name": "olake",
"iceberg_s3_path": "s3://" + tableBucketName,
"s3_endpoint": fmt.Sprintf("http://%s:%d", env.bindIP, env.s3Port),
"s3_use_ssl": false,
"s3_path_style": true,
"aws_region": "us-east-1",
"aws_access_key": env.accessKey,
"aws_secret_key": env.secretKey,
"rest_auth_type": "oauth2",
"oauth2_uri": catalogURL + "/v1/oauth/tokens",
"credential": env.accessKey + ":" + env.secretKey,
},
}
writeJSON(t, filepath.Join(env.configDir, "source.json"), source)
writeJSON(t, filepath.Join(env.configDir, "destination.json"), destination)
if err := os.WriteFile(filepath.Join(env.configDir, "state.json"), []byte("{}\n"), 0644); err != nil {
t.Fatalf("Failed to write state.json: %v", err)
}
}
func (env *TestEnvironment) runOLake(t *testing.T, args ...string) string {
t.Helper()
full := append([]string{
"run", "--rm",
"-v", dockerMount(env.configDir) + ":/mnt/config",
olakeImage(),
}, args...)
cmd := exec.Command("docker", full...)
done := make(chan struct{})
var out []byte
var err error
go func() {
out, err = cmd.CombinedOutput()
close(done)
}()
select {
case <-done:
case <-time.After(olakeRunTimeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
t.Fatalf("olake %s did not finish within %s", args[0], olakeRunTimeout)
}
if err != nil {
t.Fatalf("olake %s failed: %v\n%s", args[0], err, tailLines(string(out), 40))
}
return string(out)
}
func (env *TestEnvironment) readTable(t *testing.T, mode string) string {
t.Helper()
cmd := exec.Command("docker", "run", "--rm", readerImage, mode,
"--catalog-url", fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort),
"--warehouse", "s3://"+tableBucketName,
"--prefix", tableBucketName,
"--s3-endpoint", fmt.Sprintf("http://%s:%d", env.bindIP, env.s3Port),
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
"--namespace", expectedNamespace,
"--table", expectedTable,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("reader (%s) failed: %v\n%s", mode, err, tailLines(string(out), 30))
}
return string(out)
}
// listTableMetadata lists the table's metadata directory through the filer so
// the repair assertion looks at real objects rather than at what the catalog
// reports about itself.
func (env *TestEnvironment) listTableMetadata(t *testing.T) []string {
t.Helper()
url := fmt.Sprintf("http://%s:%d/buckets/%s/%s/%s/metadata/?limit=200",
env.bindIP, env.filerPort, tableBucketName, expectedNamespace, expectedTable)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("Failed to build filer request: %v", err)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("filer listing failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("filer listing returned %d: %s", resp.StatusCode, body)
}
var parsed struct {
Entries []struct {
FullPath string `json:"FullPath"`
} `json:"Entries"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("Failed to parse filer listing: %v\n%s", err, body)
}
names := make([]string, 0, len(parsed.Entries))
for _, e := range parsed.Entries {
names = append(names, e.FullPath[strings.LastIndex(e.FullPath, "/")+1:])
}
return names
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.postgresContainer != "" {
_ = exec.Command("docker", "rm", "-f", env.postgresContainer).Run()
}
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func buildReaderImage(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
cmd := exec.Command("docker", "build",
"-f", filepath.Join(wd, "Dockerfile.reader"),
"-t", readerImage, wd)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build reader image: %v\n%s", err, tailLines(string(out), 30))
}
}
func olakeImage() string {
if v := os.Getenv("OLAKE_IMAGE"); v != "" {
return v
}
return olakeDefaultImage
}
func postgresImage() string {
if v := os.Getenv("POSTGRES_IMAGE"); v != "" {
return v
}
return postgresDefaultImage
}
func requireOLakeRuntime(t *testing.T) {
t.Helper()
if os.Getenv("SEAWEEDFS_SKIP_OLAKE_TESTS") != "" {
t.Skip("SEAWEEDFS_SKIP_OLAKE_TESTS set")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available, skipping OLake integration test")
}
if err := exec.Command("docker", "info").Run(); err != nil {
t.Skip("docker daemon not reachable, skipping OLake integration test")
}
}
func waitForService(url string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 3 * time.Second}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode < 500 {
return true
}
}
time.Sleep(time.Second)
}
return false
}
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
body, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatalf("Failed to marshal %s: %v", path, err)
}
if err := os.WriteFile(path, append(body, '\n'), 0644); err != nil {
t.Fatalf("Failed to write %s: %v", path, err)
}
}
// dockerMount normalises a host path for a -v bind mount. Docker Desktop
// accepts forward slashes on Windows; the native separator it does not.
func dockerMount(path string) string {
return strings.ReplaceAll(path, `\`, "/")
}
func nonEmptyLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
+12 -68
View File
@@ -3,7 +3,6 @@ package cluster
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -28,7 +27,6 @@ type LockClient struct {
// correct: the filer forwards to the real primary as a fallback.
ringMu sync.RWMutex
ring *lock_manager.HashRing
ringServers []pb.ServerAddress
ringVersion int64
// priorRing is the ring before the most recent change, kept for priorWindow so a
@@ -37,24 +35,15 @@ type LockClient struct {
priorRing *lock_manager.HashRing
ringChangedAt time.Time
priorWindow time.Duration
// noLockServerRetryPeriod bounds retries when every filer reports "no
// lock server found": an empty lock ring is a systemic fault that waiting
// on a lock holder cannot resolve, unlike ordinary contention. The bound
// is long enough to ride out a master leader change (ring reset + fresh
// snapshot) but short enough that a write fails instead of hanging
// forever.
noLockServerRetryPeriod time.Duration
}
func NewLockClient(grpcDialOption grpc.DialOption, seedFiler pb.ServerAddress) *LockClient {
return &LockClient{
grpcDialOption: grpcDialOption,
maxLockDuration: 5 * time.Second,
sleepDuration: 2473 * time.Millisecond,
seedFiler: seedFiler,
priorWindow: 5 * time.Second,
noLockServerRetryPeriod: 15 * time.Second,
grpcDialOption: grpcDialOption,
maxLockDuration: 5 * time.Second,
sleepDuration: 2473 * time.Millisecond,
seedFiler: seedFiler,
priorWindow: 5 * time.Second,
}
}
@@ -69,16 +58,10 @@ func (lc *LockClient) SetRing(servers []pb.ServerAddress, version int64) {
return
}
lc.ringVersion = version
sorted := slices.Clone(servers)
slices.Sort(sorted)
if slices.Equal(sorted, lc.ringServers) {
return
}
lc.ringServers = sorted
// Build a fresh ring (not an in-place mutation) so the outgoing ring survives as
// priorRing with its own servers for the cooling-off window.
newRing := lock_manager.NewHashRing(lock_manager.DefaultVnodeCount)
newRing.SetServers(sorted)
newRing.SetServers(servers)
if lc.ring != nil {
lc.priorRing = lc.ring
lc.ringChangedAt = time.Now()
@@ -86,18 +69,6 @@ func (lc *LockClient) SetRing(servers []pb.ServerAddress, version int64) {
lc.ring = newRing
}
// ResetRing clears only the version gate so the first update from a
// different master applies unconditionally: ring versions are per-master
// monotonic and a high version accepted from a former leader must not
// reject the new leader's snapshot. The last ring keeps routing during the
// gap rather than falling back to the seed filer, and the arriving ring
// becomes the prior ring for the cooling-off window.
func (lc *LockClient) ResetRing() {
lc.ringMu.Lock()
defer lc.ringMu.Unlock()
lc.ringVersion = 0
}
// hostForKey returns the filer that should own key per the current ring view,
// falling back to the seed filer when no view has been received yet.
func (lc *LockClient) hostForKey(key string) pb.ServerAddress {
@@ -162,9 +133,7 @@ type LiveLock struct {
consecutiveFailures int // Track connection failures to trigger fallback
}
// NewShortLivedLock creates a lock with a 5-second duration.
// It returns nil when the lock cannot be acquired because no lock server
// exists; ordinary contention is still waited out.
// NewShortLivedLock creates a lock with a 5-second duration
func (lc *LockClient) NewShortLivedLock(key string, owner string) (lock *LiveLock) {
lock = &LiveLock{
key: key,
@@ -175,10 +144,7 @@ func (lc *LockClient) NewShortLivedLock(key string, owner string) (lock *LiveLoc
self: owner,
lc: lc,
}
if err := lock.retryUntilLocked(5 * time.Second); err != nil {
glog.Warningf("create lock %s: %v", key, err)
return nil
}
lock.retryUntilLocked(5 * time.Second)
return
}
@@ -201,10 +167,7 @@ func (lc *LockClient) NewBlockingLongLivedLock(key, owner string, lockTTL time.D
lockTTL: lockTTL,
}
// Block until acquired
if err := lock.retryUntilLocked(lockTTL); err != nil {
glog.Warningf("create lock %s: %v", key, err)
return nil
}
lock.retryUntilLocked(lockTTL)
// Start renewal goroutine using a ticker for interruptible sleep
lock.renewalDone = make(chan struct{})
go func() {
@@ -303,31 +266,12 @@ func (lc *LockClient) StartLongLivedLock(key string, owner string, onLockOwnerCh
// several seconds): when a holder on another mount releases the lock, the
// waiter must pick it up promptly, otherwise cross-mount write handoff stalls
// long enough to time out clients.
func (lock *LiveLock) retryUntilLocked(lockDuration time.Duration) error {
var unavailableSince time.Time
func (lock *LiveLock) retryUntilLocked(lockDuration time.Duration) {
for lock.renewToken == "" {
err := lock.AttemptToLock(lockDuration)
if err == nil {
unavailableSince = time.Time{}
continue
}
glog.V(1).Infof("create lock %s: %v", lock.key, err)
if strings.Contains(err.Error(), "lock already owned") {
// Ordinary contention: a reachable server holds the lock, so
// waiting is the point and has no bound.
unavailableSince = time.Time{}
continue
}
// Anything else — "no lock server found", a dead ring member refusing
// connections — is a systemic fault waiting cannot fix; give up once
// it persists past the retry period.
if unavailableSince.IsZero() {
unavailableSince = time.Now()
} else if time.Since(unavailableSince) > lock.lc.noLockServerRetryPeriod {
return err
if err := lock.AttemptToLock(lockDuration); err != nil {
glog.V(1).Infof("create lock %s: %v", lock.key, err)
}
}
return nil
}
func (lock *LiveLock) AttemptToLock(lockDuration time.Duration) error {
-134
View File
@@ -1,18 +1,13 @@
package cluster
import (
"context"
"fmt"
"net"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// The gateway must resolve a lock key to the same primary the filers do,
@@ -157,135 +152,6 @@ func TestLockClientPriorOwnerForKeyExpires(t *testing.T) {
}
}
// A master change clears the version gate so the new leader's (lower-versioned)
// snapshot applies — versions are only comparable within one master's stream.
func TestLockClientResetRing(t *testing.T) {
lc := NewLockClient(nil, "seed:8888")
lc.SetRing([]pb.ServerAddress{"filer-a:8888", "filer-b:8888"}, 100)
lc.ResetRing()
// The last ring keeps routing during the gap; only version acceptance
// is reset so the new leader's (lower-versioned) snapshot applies.
if got := lc.hostForKey("k"); got == "seed:8888" {
t.Fatal("expected the previous ring to keep routing after reset")
}
lc.SetRing([]pb.ServerAddress{"filer-z:8888"}, 50)
if got := lc.hostForKey("k"); got != "filer-z:8888" {
t.Fatalf("lower version from new master not applied, got %q", got)
}
}
type noLockServerFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
}
func (s *noLockServerFiler) DistributedLock(ctx context.Context, req *filer_pb.LockRequest) (*filer_pb.LockResponse, error) {
return &filer_pb.LockResponse{Error: lock_manager.NoLockServerError.Error()}, nil
}
// When every filer reports an empty lock ring, lock acquisition must fail
// after a bounded period instead of hanging the write forever.
func TestNewShortLivedLockFailsFastOnNoLockServer(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcServer := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(grpcServer, &noLockServerFiler{})
go grpcServer.Serve(listener)
defer grpcServer.Stop()
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
host, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatalf("split host port: %v", err)
}
// "host:httpPort.grpcPort" dials the fake filer's port directly.
lc := NewLockClient(dialOption, pb.ServerAddress(fmt.Sprintf("%s:0.%s", host, port)))
lc.noLockServerRetryPeriod = 200 * time.Millisecond
start := time.Now()
lock := lc.NewShortLivedLock("test-key", "test-owner")
elapsed := time.Since(start)
if lock != nil {
t.Fatal("expected nil lock when no lock server exists")
}
if elapsed > 10*time.Second {
t.Fatalf("lock acquisition took %v, expected fail-fast", elapsed)
}
}
type contendedLockFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
}
func (s *contendedLockFiler) DistributedLock(ctx context.Context, req *filer_pb.LockRequest) (*filer_pb.LockResponse, error) {
return &filer_pb.LockResponse{Error: "lock already owned by someone"}, nil
}
// A lock held by another owner is ordinary contention: acquisition waits it
// out rather than failing on the unavailability bound.
func TestNewShortLivedLockWaitsOutContention(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcServer := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(grpcServer, &contendedLockFiler{})
go grpcServer.Serve(listener)
defer grpcServer.Stop()
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
host, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatalf("split host port: %v", err)
}
lc := NewLockClient(dialOption, pb.ServerAddress(fmt.Sprintf("%s:0.%s", host, port)))
lc.noLockServerRetryPeriod = 200 * time.Millisecond
done := make(chan *LiveLock, 1)
go func() {
done <- lc.NewShortLivedLock("test-key", "test-owner")
}()
select {
case <-done:
t.Fatal("ordinary lock contention must not hit the unavailability bound")
case <-time.After(3 * lc.noLockServerRetryPeriod):
}
}
// A ring member that refuses connections is unavailability, not contention:
// acquisition fails on the same bound as "no lock server found".
func TestNewShortLivedLockFailsFastOnUnreachableFiler(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
listener.Close()
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host port: %v", err)
}
lc := NewLockClient(dialOption, pb.ServerAddress(fmt.Sprintf("%s:0.%s", host, port)))
lc.noLockServerRetryPeriod = 200 * time.Millisecond
start := time.Now()
lock := lc.NewShortLivedLock("test-key", "test-owner")
elapsed := time.Since(start)
if lock != nil {
t.Fatal("expected nil lock when the ring member is unreachable")
}
if elapsed > 10*time.Second {
t.Fatalf("lock acquisition took %v, expected fail-fast", elapsed)
}
}
// LiveLock.generation is a fencing token written and read with 64-bit atomic
// operations. On 32-bit platforms (GOARCH=386 and GOARCH=arm) a 64-bit atomic
// op requires an 8-byte-aligned address, which Go only guarantees for the
-22
View File
@@ -1,7 +1,6 @@
package lock_manager
import (
"slices"
"sort"
"sync"
"time"
@@ -55,14 +54,6 @@ func (r *LockRing) SetSnapshot(servers []pb.ServerAddress, version int64) bool {
r.Unlock()
return false
}
// An unchanged member list is only a version refresh: installing it as a
// new snapshot would run the topology-change callback and restart the
// prior-owner window on every periodic rebroadcast.
if len(r.snapshots) > 0 && slices.Equal(servers, r.snapshots[0].servers) {
r.version = version
r.Unlock()
return true
}
r.version = version
// Update the ring while holding the lock so version and ring state
// are always consistent — prevents a concurrent SetSnapshot from
@@ -83,19 +74,6 @@ func (r *LockRing) SetSnapshot(servers []pb.ServerAddress, version int64) bool {
return true
}
// Reset clears only the version gate so the first update from a different
// master always applies: ring versions are per-master monotonic, and a high
// version accepted from a former leader must not reject the new leader's
// view. The ring itself stays installed — writes keep routing to the last
// known owner during the gap instead of every filer treating itself as the
// owner, and the arriving snapshot transitions off it with the usual
// prior-owner window.
func (r *LockRing) Reset() {
r.Lock()
defer r.Unlock()
r.version = 0
}
// Version returns the current ring version.
func (r *LockRing) Version() int64 {
r.RLock()
@@ -96,42 +96,3 @@ func TestLockRing_VersionRejectsStale(t *testing.T) {
assert.True(t, ok)
assert.Equal(t, 1, len(r.GetSnapshot()))
}
func TestLockRing_SetSnapshotUnchangedOnlyBumpsVersion(t *testing.T) {
r := NewLockRing(100 * time.Millisecond)
callbacks := 0
r.SetTakeSnapshotCallback(func(snapshot []pb.ServerAddress) { callbacks++ })
assert.True(t, r.SetSnapshot([]pb.ServerAddress{"a:1", "b:2"}, 100))
assert.Equal(t, 1, callbacks)
// A periodic rebroadcast with the same members refreshes the version
// without a new snapshot or another topology-change callback.
assert.True(t, r.SetSnapshot([]pb.ServerAddress{"b:2", "a:1"}, 200))
assert.Equal(t, int64(200), r.Version())
assert.Equal(t, 1, r.GetSnapshotCount())
assert.Equal(t, 1, callbacks, "unchanged ring must not fire the topology callback")
assert.True(t, r.SetSnapshot([]pb.ServerAddress{"a:1", "b:2", "c:3"}, 300))
assert.Equal(t, 2, callbacks)
}
func TestLockRing_Reset(t *testing.T) {
r := NewLockRing(100 * time.Millisecond)
// A high version accepted from a former leader must not reject the new
// leader's view once the client has moved masters.
ok := r.SetSnapshot([]pb.ServerAddress{"a:1", "b:2"}, 100)
assert.True(t, ok)
r.Reset()
assert.Equal(t, int64(0), r.Version())
// The operational ring survives the reset: writes keep routing to the
// last known owner until the new leader's snapshot arrives.
assert.Equal(t, 2, len(r.GetSnapshot()))
assert.NotEqual(t, "", string(r.GetPrimary("key")))
ok = r.SetSnapshot([]pb.ServerAddress{"c:1"}, 50)
assert.True(t, ok, "lower version from a different master must apply after reset")
assert.Equal(t, 1, len(r.GetSnapshot()))
}
+27 -81
View File
@@ -11,38 +11,29 @@ import (
const LockRingStabilizationInterval = 1 * time.Second
// LockRingRebroadcastInterval is how often the ring is re-sent even when
// membership has not changed. Broadcasts are otherwise purely event-driven,
// so a single lost or poisoned update would be permanent without this.
const LockRingRebroadcastInterval = 30 * time.Second
// LockRingManager tracks filer membership for the distributed lock ring.
// It batches rapid topology changes (e.g., node drop + join) with a
// stabilization timer, then broadcasts the complete member list atomically
// so filers receive a single consistent ring update instead of multiple
// intermediate states.
type LockRingManager struct {
mu sync.Mutex
members map[FilerGroupName]map[pb.ServerAddress]struct{}
version map[FilerGroupName]int64
lastBroadcast map[FilerGroupName]*master_pb.LockRingUpdate
pendingTimer map[FilerGroupName]*time.Timer
rebroadcastTimer map[FilerGroupName]*time.Timer
broadcastFn func(resp *master_pb.KeepConnectedResponse)
stabilizeDelay time.Duration
rebroadcastInterval time.Duration
mu sync.Mutex
members map[FilerGroupName]map[pb.ServerAddress]struct{}
version map[FilerGroupName]int64
lastBroadcast map[FilerGroupName]*master_pb.LockRingUpdate
pendingTimer map[FilerGroupName]*time.Timer
broadcastFn func(resp *master_pb.KeepConnectedResponse)
stabilizeDelay time.Duration
}
func NewLockRingManager(broadcastFn func(resp *master_pb.KeepConnectedResponse)) *LockRingManager {
return &LockRingManager{
members: make(map[FilerGroupName]map[pb.ServerAddress]struct{}),
version: make(map[FilerGroupName]int64),
lastBroadcast: make(map[FilerGroupName]*master_pb.LockRingUpdate),
pendingTimer: make(map[FilerGroupName]*time.Timer),
rebroadcastTimer: make(map[FilerGroupName]*time.Timer),
broadcastFn: broadcastFn,
stabilizeDelay: LockRingStabilizationInterval,
rebroadcastInterval: LockRingRebroadcastInterval,
members: make(map[FilerGroupName]map[pb.ServerAddress]struct{}),
version: make(map[FilerGroupName]int64),
lastBroadcast: make(map[FilerGroupName]*master_pb.LockRingUpdate),
pendingTimer: make(map[FilerGroupName]*time.Timer),
broadcastFn: broadcastFn,
stabilizeDelay: LockRingStabilizationInterval,
}
}
@@ -125,64 +116,16 @@ func (lrm *LockRingManager) scheduleBroadcast(filerGroup FilerGroupName) {
func (lrm *LockRingManager) doBroadcast(filerGroup FilerGroupName) {
lrm.mu.Lock()
delete(lrm.pendingTimer, filerGroup)
lrm.mu.Unlock()
lrm.emit(filerGroup)
}
// rebroadcast re-sends the current ring unless a membership broadcast is
// still stabilizing — emitting mid-window would publish an intermediate
// topology that the pending timer immediately replaces. The check and the
// update must sit in one critical section or a membership change can slip
// a pending timer in between.
func (lrm *LockRingManager) rebroadcast(filerGroup FilerGroupName) {
lrm.mu.Lock()
var update *master_pb.LockRingUpdate
if _, pending := lrm.pendingTimer[filerGroup]; !pending {
update = lrm.nextBroadcastUpdate(filerGroup)
}
lrm.mu.Unlock()
lrm.sendUpdate(filerGroup, update)
}
func (lrm *LockRingManager) emit(filerGroup FilerGroupName) {
lrm.mu.Lock()
update := lrm.nextBroadcastUpdate(filerGroup)
lrm.mu.Unlock()
lrm.sendUpdate(filerGroup, update)
}
func (lrm *LockRingManager) sendUpdate(filerGroup FilerGroupName, update *master_pb.LockRingUpdate) {
if update == nil {
return
}
glog.V(0).Infof("LockRing: broadcasting ring update for group %q version %d: %v", filerGroup, update.Version, update.Servers)
if lrm.broadcastFn != nil {
lrm.broadcastFn(&master_pb.KeepConnectedResponse{
LockRingUpdate: update,
})
}
}
// nextBroadcastUpdate stamps the current members into an update and re-arms
// the periodic rebroadcast. It returns nil for an empty member list: an empty
// lock ring is never usable, so a late "last member removed" event from a
// former leader must not propagate and wedge every lock client. The last
// non-empty broadcast stays in lastBroadcast for reconnecting clients.
// Caller must hold lrm.mu.
func (lrm *LockRingManager) nextBroadcastUpdate(filerGroup FilerGroupName) *master_pb.LockRingUpdate {
members := lrm.members[filerGroup]
if len(members) == 0 {
return nil
}
// Use wall-clock nanoseconds so the version survives master restarts
// without persistence — a restarted master produces a version greater
// than any pre-restart value (assuming clocks don't jump backward).
version := time.Now().UnixNano()
lrm.version[filerGroup] = version
servers := make([]string, 0, len(members))
for addr := range members {
servers = append(servers, string(addr))
servers := make([]string, 0)
if members, ok := lrm.members[filerGroup]; ok {
for addr := range members {
servers = append(servers, string(addr))
}
}
update := &master_pb.LockRingUpdate{
FilerGroup: string(filerGroup),
@@ -190,13 +133,16 @@ func (lrm *LockRingManager) nextBroadcastUpdate(filerGroup FilerGroupName) *mast
Version: version,
}
lrm.lastBroadcast[filerGroup] = update
if timer, ok := lrm.rebroadcastTimer[filerGroup]; ok {
timer.Stop()
delete(lrm.pendingTimer, filerGroup)
lrm.mu.Unlock()
glog.V(0).Infof("LockRing: broadcasting ring update for group %q version %d: %v", filerGroup, version, servers)
if lrm.broadcastFn != nil {
lrm.broadcastFn(&master_pb.KeepConnectedResponse{
LockRingUpdate: update,
})
}
lrm.rebroadcastTimer[filerGroup] = time.AfterFunc(lrm.rebroadcastInterval, func() {
lrm.rebroadcast(filerGroup)
})
return update
}
// FlushPending fires any pending timer immediately (for testing or shutdown).
-105
View File
@@ -213,111 +213,6 @@ func TestLockRingManager_NoBroadcastWithoutFn(t *testing.T) {
time.Sleep(50 * time.Millisecond) // should not panic
}
func TestLockRingManager_EmptyRingNotBroadcast(t *testing.T) {
var mu sync.Mutex
var broadcasts []*master_pb.LockRingUpdate
lrm := NewLockRingManager(func(resp *master_pb.KeepConnectedResponse) {
mu.Lock()
if resp.LockRingUpdate != nil {
broadcasts = append(broadcasts, resp.LockRingUpdate)
}
mu.Unlock()
})
lrm.stabilizeDelay = 50 * time.Millisecond
group := FilerGroupName("default")
lrm.AddServer(group, "filer1:8888")
lrm.FlushPending(group)
mu.Lock()
broadcasts = nil
mu.Unlock()
// Removing the last member must not propagate an empty ring.
lrm.RemoveServer(group, "filer1:8888")
time.Sleep(100 * time.Millisecond)
mu.Lock()
assert.Equal(t, 0, len(broadcasts), "empty ring must not be broadcast")
mu.Unlock()
// The last non-empty snapshot is still served to reconnecting clients.
update := lrm.GetLastUpdate(group)
require.NotNil(t, update)
assert.Equal(t, []string{"filer1:8888"}, update.Servers)
}
func TestLockRingManager_PeriodicRebroadcast(t *testing.T) {
var mu sync.Mutex
var broadcasts []*master_pb.LockRingUpdate
lrm := NewLockRingManager(func(resp *master_pb.KeepConnectedResponse) {
mu.Lock()
if resp.LockRingUpdate != nil {
broadcasts = append(broadcasts, resp.LockRingUpdate)
}
mu.Unlock()
})
lrm.stabilizeDelay = 20 * time.Millisecond
lrm.rebroadcastInterval = 60 * time.Millisecond
group := FilerGroupName("default")
lrm.AddServer(group, "filer1:8888")
// Without any further membership change, the ring keeps being re-sent so
// a lost or poisoned update cannot be permanent.
time.Sleep(200 * time.Millisecond)
mu.Lock()
require.GreaterOrEqual(t, len(broadcasts), 2, "ring should rebroadcast periodically")
for i := 1; i < len(broadcasts); i++ {
assert.Greater(t, broadcasts[i].Version, broadcasts[i-1].Version)
}
mu.Unlock()
}
func TestLockRingManager_RebroadcastDefersToPendingStabilization(t *testing.T) {
var mu sync.Mutex
var broadcasts []*master_pb.LockRingUpdate
lrm := NewLockRingManager(func(resp *master_pb.KeepConnectedResponse) {
mu.Lock()
if resp.LockRingUpdate != nil {
broadcasts = append(broadcasts, resp.LockRingUpdate)
}
mu.Unlock()
})
lrm.stabilizeDelay = 100 * time.Millisecond
lrm.rebroadcastInterval = 30 * time.Millisecond
group := FilerGroupName("default")
lrm.AddServer(group, "filer1:8888")
lrm.FlushPending(group)
mu.Lock()
require.Len(t, broadcasts, 1)
mu.Unlock()
// A membership change just before the periodic tick: the rebroadcast must
// not publish the unsettled ring ahead of the stabilization timer.
lrm.RemoveServer(group, "filer1:8888")
lrm.AddServer(group, "filer2:8888")
time.Sleep(2 * lrm.rebroadcastInterval)
mu.Lock()
assert.Len(t, broadcasts, 1, "rebroadcast during stabilization should be deferred")
mu.Unlock()
time.Sleep(2 * lrm.stabilizeDelay)
mu.Lock()
require.GreaterOrEqual(t, len(broadcasts), 2)
assert.Equal(t, []string{"filer2:8888"}, broadcasts[1].Servers)
mu.Unlock()
}
func TestLockRingManager_GetLastUpdateReturnsBroadcastState(t *testing.T) {
lrm := NewLockRingManager(nil)
+1 -1
View File
@@ -156,7 +156,7 @@ func backupFromLocation(volumeServer pb.ServerAddress, grpcDialOption grpc.DialO
// If local volume is larger than remote, recreate it
if datSize > stats.TailOffset {
if err := v.Destroy(false, false, false); err != nil {
if err := v.Destroy(false, false); err != nil {
v.Close()
return fmt.Errorf("destroying volume: %w", err), false
}
-3
View File
@@ -89,7 +89,6 @@ type FilerOptions struct {
s3ConfigFile *string // optional path to static S3 identity config
allowUntrustedRemoteEndpoints *bool
remoteCacheEvictThreshold *float64
// shutdownCtx, when non-nil, tells startFiler to gracefully shut down its
// HTTP/gRPC servers once the ctx is cancelled. Used by integration tests
// and by weed mini; nil for standalone weed filer.
@@ -135,7 +134,6 @@ func init() {
f.tusMaxSizeMB = cmdFiler.Flag.Int("tusMaxSizeMB", 5*1024, "maximum TUS upload size in MB")
f.tusSessionExpiry = cmdFiler.Flag.Duration("tusSessionExpiry", 24*time.Hour, "incomplete TUS upload sessions are cleaned up after this duration, e.g. \"48h\", \"7h30m\"")
f.allowUntrustedRemoteEndpoints = cmdFiler.Flag.Bool("allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage)
f.remoteCacheEvictThreshold = cmdFiler.Flag.Float64("remoteCacheEvictThreshold", 0.9, "evict remote-cached objects (oldest first) when any volume disk exceeds this usage fraction; 0 disables")
// start s3 on filer
filerStartS3 = cmdFiler.Flag.Bool("s3", false, "whether to start S3 gateway")
@@ -409,7 +407,6 @@ func (fo *FilerOptions) startFiler() {
CredentialManager: credentialManager,
AllowUntrustedRemoteEndpoints: *fo.allowUntrustedRemoteEndpoints,
RemoteCacheEvictThreshold: *fo.remoteCacheEvictThreshold,
})
if nfs_err != nil {
glog.Fatalf("Filer startup error: %v", nfs_err)
+17 -23
View File
@@ -46,19 +46,17 @@ const (
)
type MasterOptions struct {
port *int
portGrpc *int
ip *string
ipBind *string
metaFolder *string
peers *string
mastersDeprecated *string // deprecated, for backward compatibility in master.follower
volumeSizeLimitMB *uint
fileSizeLimitMB *int
volumePreallocate *bool
maxParallelVacuumPerServer *int
vacuumIntervalSeconds *int
vacuumDeleteEmptyAfterSeconds *int
port *int
portGrpc *int
ip *string
ipBind *string
metaFolder *string
peers *string
mastersDeprecated *string // deprecated, for backward compatibility in master.follower
volumeSizeLimitMB *uint
fileSizeLimitMB *int
volumePreallocate *bool
maxParallelVacuumPerServer *int
// pulseSeconds *int
defaultReplication *string
garbageThreshold *float64
@@ -95,8 +93,6 @@ func init() {
m.fileSizeLimitMB = cmdMaster.Flag.Int("fileSizeLimitMB", 256, "limit the file size accepted by /submit, should match the volume servers' -fileSizeLimitMB (-volume.fileSizeLimitMB under weed server or weed mini, which set this for you)")
m.volumePreallocate = cmdMaster.Flag.Bool("volumePreallocate", false, "Preallocate disk space for volumes.")
m.maxParallelVacuumPerServer = cmdMaster.Flag.Int("maxParallelVacuumPerServer", 1, "maximum number of volumes to vacuum in parallel per volume server")
m.vacuumIntervalSeconds = cmdMaster.Flag.Int("vacuumIntervalSeconds", 840, "seconds between automatic vacuum sweeps")
m.vacuumDeleteEmptyAfterSeconds = cmdMaster.Flag.Int("vacuumDeleteEmptyAfterSeconds", 0, "automatic sweep deletes volume copies that stay empty this many seconds; 0 disables")
// m.pulseSeconds = cmdMaster.Flag.Int("pulseSeconds", 5, "number of seconds between heartbeats")
m.defaultReplication = cmdMaster.Flag.String("defaultReplication", "", "Default replication type if not specified.")
m.garbageThreshold = cmdMaster.Flag.Float64("garbageThreshold", 0.3, "threshold to vacuum and reclaim spaces")
@@ -471,14 +467,12 @@ func peerIndex(self pb.ServerAddress, peers []pb.ServerAddress) int {
func (m *MasterOptions) toMasterOption(whiteList []string) *weed_server.MasterOption {
masterAddress := pb.NewServerAddress(*m.ip, *m.port, *m.portGrpc)
return &weed_server.MasterOption{
Master: masterAddress,
MetaFolder: *m.metaFolder,
VolumeSizeLimitMB: uint32(*m.volumeSizeLimitMB),
FileSizeLimitMB: *m.fileSizeLimitMB,
VolumePreallocate: *m.volumePreallocate,
MaxParallelVacuumPerServer: *m.maxParallelVacuumPerServer,
VacuumIntervalSeconds: *m.vacuumIntervalSeconds,
VacuumDeleteEmptyAfterSeconds: *m.vacuumDeleteEmptyAfterSeconds,
Master: masterAddress,
MetaFolder: *m.metaFolder,
VolumeSizeLimitMB: uint32(*m.volumeSizeLimitMB),
FileSizeLimitMB: *m.fileSizeLimitMB,
VolumePreallocate: *m.volumePreallocate,
MaxParallelVacuumPerServer: *m.maxParallelVacuumPerServer,
// PulseSeconds: *m.pulseSeconds,
DefaultReplicaPlacement: *m.defaultReplication,
GarbageThreshold: *m.garbageThreshold,
-2
View File
@@ -47,8 +47,6 @@ func init() {
mf.metricsIntervalSec = aws.Int(0)
mf.raftResumeState = aws.Bool(false)
mf.maxParallelVacuumPerServer = aws.Int(1)
mf.vacuumIntervalSeconds = aws.Int(840)
mf.vacuumDeleteEmptyAfterSeconds = aws.Int(0)
mf.telemetryUrl = aws.String("https://telemetry.seaweedfs.com/api/collect")
mf.telemetryEnabled = aws.Bool(false)
}
-3
View File
@@ -427,8 +427,6 @@ func initMiniMasterFlags() {
miniMasterOptions.volumeSizeLimitMB = cmdMini.Flag.Uint("master.volumeSizeLimitMB", defaultMiniVolumeSizeMB, "Master stops directing writes to oversized volumes (default: 128MB for mini)")
miniMasterOptions.volumePreallocate = cmdMini.Flag.Bool("master.volumePreallocate", false, "Preallocate disk space for volumes.")
miniMasterOptions.maxParallelVacuumPerServer = cmdMini.Flag.Int("master.maxParallelVacuumPerServer", 1, "maximum number of volumes to vacuum in parallel on one volume server")
miniMasterOptions.vacuumIntervalSeconds = cmdMini.Flag.Int("master.vacuumIntervalSeconds", 840, "seconds between automatic vacuum sweeps")
miniMasterOptions.vacuumDeleteEmptyAfterSeconds = cmdMini.Flag.Int("master.vacuumDeleteEmptyAfterSeconds", 0, "automatic sweep deletes volume copies that stay empty this many seconds; 0 disables")
miniMasterOptions.defaultReplication = cmdMini.Flag.String("master.defaultReplication", "", "Default replication type if not specified.")
miniMasterOptions.garbageThreshold = cmdMini.Flag.Float64("master.garbageThreshold", 0.3, "threshold to vacuum and reclaim spaces")
miniMasterOptions.metricsAddress = cmdMini.Flag.String("master.metrics.address", "", "Prometheus gateway address")
@@ -467,7 +465,6 @@ func initMiniFilerFlags() {
miniFilerOptions.tusMaxSizeMB = cmdMini.Flag.Int("filer.tusMaxSizeMB", 5*1024, "maximum TUS upload size in MB")
miniFilerOptions.tusSessionExpiry = cmdMini.Flag.Duration("filer.tusSessionExpiry", 24*time.Hour, "incomplete TUS upload sessions are cleaned up after this duration")
miniFilerOptions.allowUntrustedRemoteEndpoints = cmdMini.Flag.Bool("filer.allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage)
miniFilerOptions.remoteCacheEvictThreshold = cmdMini.Flag.Float64("filer.remoteCacheEvictThreshold", 0.9, "evict remote-cached objects (oldest first) when any volume disk exceeds this usage fraction; 0 disables")
}
// initMiniVolumeFlags initializes Volume server flag options
-1
View File
@@ -618,7 +618,6 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) {
lanceRouter.Use(util_http.EscapeSemicolonsInQuery)
lanceServer := lance.NewServer(s3ApiServer, s3ApiServer)
lanceServer.SetCredentialValidator(s3ApiServer)
if s3opt.icebergCredentialRole != nil && *s3opt.icebergCredentialRole != "" {
lanceServer.SetCredentialVendor(lanceCredentialVendor{s3ApiServer})
}
-1
View File
@@ -41,7 +41,6 @@ copy_2 = 6 # create 2 x 6 = 12 actual volumes
copy_3 = 3 # create 3 x 3 = 9 actual volumes
copy_other = 1 # create n x 1 = n actual volumes
threshold = 0.9 # create threshold
reservation_timeout = "5m"# capacity reservation timeout before unreleased reservations expire
disable = false # disables volume growth if true
# configuration flags for replication
-3
View File
@@ -98,8 +98,6 @@ func init() {
masterOptions.volumeSizeLimitMB = cmdServer.Flag.Uint("master.volumeSizeLimitMB", util.DefaultVolumeSizeLimitMB, "Master stops directing writes to oversized volumes.")
masterOptions.volumePreallocate = cmdServer.Flag.Bool("master.volumePreallocate", false, "Preallocate disk space for volumes.")
masterOptions.maxParallelVacuumPerServer = cmdServer.Flag.Int("master.maxParallelVacuumPerServer", 1, "maximum number of volumes to vacuum in parallel on one volume server")
masterOptions.vacuumIntervalSeconds = cmdServer.Flag.Int("master.vacuumIntervalSeconds", 840, "seconds between automatic vacuum sweeps")
masterOptions.vacuumDeleteEmptyAfterSeconds = cmdServer.Flag.Int("master.vacuumDeleteEmptyAfterSeconds", 0, "automatic sweep deletes volume copies that stay empty this many seconds; 0 disables")
masterOptions.defaultReplication = cmdServer.Flag.String("master.defaultReplication", "", "Default replication type if not specified.")
masterOptions.garbageThreshold = cmdServer.Flag.Float64("master.garbageThreshold", 0.3, "threshold to vacuum and reclaim spaces")
masterOptions.metricsAddress = cmdServer.Flag.String("master.metrics.address", "", "Prometheus gateway address")
@@ -136,7 +134,6 @@ func init() {
filerOptions.tusMaxSizeMB = cmdServer.Flag.Int("filer.tusMaxSizeMB", 5*1024, "maximum TUS upload size in MB")
filerOptions.tusSessionExpiry = cmdServer.Flag.Duration("filer.tusSessionExpiry", 24*time.Hour, "incomplete TUS upload sessions are cleaned up after this duration, e.g. \"48h\", \"7h30m\"")
filerOptions.allowUntrustedRemoteEndpoints = cmdServer.Flag.Bool("filer.allowUntrustedRemoteEndpoints", false, allowUntrustedRemoteEndpointsUsage)
filerOptions.remoteCacheEvictThreshold = cmdServer.Flag.Float64("filer.remoteCacheEvictThreshold", 0.9, "evict remote-cached objects (oldest first) when any volume disk exceeds this usage fraction; 0 disables")
serverOptions.v.port = cmdServer.Flag.Int("volume.port", 8080, "volume server http listen port")
serverOptions.v.portGrpc = cmdServer.Flag.Int("volume.port.grpc", 0, "volume server grpc listen port")
+1 -1
View File
@@ -43,7 +43,7 @@ func markVolumeReplicaWritable(ctx context.Context, grpcDialOption grpc.DialOpti
// deleteVolume removes the volume from sourceVolumeServer via the canonical
// volume_move helper.
func deleteVolume(ctx context.Context, grpcDialOption grpc.DialOption, volumeId needle.VolumeId, sourceVolumeServer pb.ServerAddress, onlyEmpty bool, keepRemoteData bool) (err error) {
return volume_move.NewMover(grpcDialOption).DeleteVolume(ctx, volumeId, sourceVolumeServer, onlyEmpty, false, keepRemoteData)
return volume_move.NewMover(grpcDialOption).DeleteVolume(ctx, volumeId, sourceVolumeServer, onlyEmpty, keepRemoteData)
}
func ChunkVolumeIds(volumeIds []needle.VolumeId, batchSize int) [][]needle.VolumeId {
+5 -34
View File
@@ -7,7 +7,6 @@ import (
"os"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
@@ -23,7 +22,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -75,11 +73,6 @@ type Filer struct {
EmptyFolderCleanupDelay time.Duration
persistedLogCache *persistedLogCache
metaLogInflight metaLogInflight
remoteTombstones *remoteDeletionTombstones
// remoteTombstonesDone, when non-nil, is closed once the startup tombstone
// rebuild finishes; lazy remote reads wait on it so a pending delete
// cannot resurrect in the gap.
remoteTombstonesDone atomic.Pointer[chan struct{}]
}
func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerHost pb.ServerAddress, filerGroup string, collection string, replication string, dataCenter string, maxFilenameLength uint32, notifyFn func()) *Filer {
@@ -95,7 +88,6 @@ func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerH
deletionQuit: make(chan struct{}),
DeletionRetryQueue: NewDeletionRetryQueue(),
persistedLogCache: newPersistedLogCache(persistedLogCacheMaxBytes),
remoteTombstones: newRemoteDeletionTombstones(),
}
if f.UniqueFilerId < 0 {
f.UniqueFilerId = -f.UniqueFilerId
@@ -182,10 +174,6 @@ func (f *Filer) AggregateFromPeers(self pb.ServerAddress, existingNodes []*maste
glog.V(0).Infof("LockRing: applying master ring update v%d: %v", update.Version, servers)
f.Dlm.LockRing.SetSnapshot(servers, update.Version)
})
f.MasterClient.SetOnMasterChangeFn(func(previous, current pb.ServerAddress) {
glog.V(0).Infof("LockRing: master changed %s -> %s, resetting ring", previous, current)
f.Dlm.LockRing.Reset()
})
// Subscribe to the local filer first: its events reach the aggregated
// buffer only through this subscription, and the peer watermarks must
@@ -277,13 +265,7 @@ func (f *Filer) CreateEntry(ctx context.Context, entry *Entry, existing *Entry,
oldEntry := existing
if oldEntry == nil {
var findErr error
oldEntry, findErr = f.FindEntry(ctx, entry.FullPath)
if o_excl && findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) {
// An exclusive create cannot decide whether the path exists when
// the lookup itself failed; proceeding would upsert over it.
return fmt.Errorf("find entry %s: %w", entry.FullPath, findErr)
}
oldEntry, _ = f.FindEntry(ctx, entry.FullPath)
}
/*
@@ -328,7 +310,7 @@ func (f *Filer) CreateEntry(ctx context.Context, entry *Entry, existing *Entry,
return fmt.Errorf("%s: %w", entry.FullPath, filer_pb.ErrEntryAlreadyExists)
}
glog.V(4).InfofCtx(ctx, "UpdateEntry %s: old entry: %v", entry.FullPath, oldEntry.Name())
if err := f.UpdateEntry(ctx, oldEntry, entry, isFromOtherCluster); err != nil {
if err := f.UpdateEntry(ctx, oldEntry, entry); err != nil {
if errors.Is(err, filer_pb.ErrExistingIsDirectory) || errors.Is(err, filer_pb.ErrExistingIsFile) {
glog.V(2).InfofCtx(ctx, "update entry %s: %v", entry.FullPath, err)
} else {
@@ -483,7 +465,7 @@ func (f *Filer) EnsureDirectoryEntry(ctx context.Context, dirPath util.FullPath,
narrowed := existing.ShallowClone()
narrowed.Mode = existing.Mode&^restorableModeBits | kept
glog.V(1).InfofCtx(ctx, "restore directory %s: narrowing %v to %v", dirPath, existing.Mode, narrowed.Mode)
if err := f.UpdateEntry(ctx, existing, narrowed, false); err != nil {
if err := f.UpdateEntry(ctx, existing, narrowed); err != nil {
return err
}
f.NotifyUpdateEvent(ctx, existing, narrowed, false, false, nil)
@@ -516,7 +498,7 @@ func (f *Filer) EnsureDirectoryEntry(ctx context.Context, dirPath util.FullPath,
return nil
}
func (f *Filer) UpdateEntry(ctx context.Context, oldEntry, entry *Entry, isFromOtherCluster bool) (err error) {
func (f *Filer) UpdateEntry(ctx context.Context, oldEntry, entry *Entry) (err error) {
if oldEntry != nil {
entry.Attr.Crtime = oldEntry.Attr.Crtime
if oldEntry.Attr.Inode != 0 {
@@ -536,17 +518,6 @@ func (f *Filer) UpdateEntry(ctx context.Context, oldEntry, entry *Entry, isFromO
glog.V(2).InfofCtx(ctx, "existing %s is a file", oldEntry.FullPath)
return fmt.Errorf("%s: %w", oldEntry.FullPath, filer_pb.ErrExistingIsFile)
}
// A local write to a remote-backed entry leaves the copy on remote
// stale until a sync re-uploads it. Content changes that arrive
// without a fresh sync stamp are unsynced; clear the stamp so nothing
// mistakes the still-local chunks for a re-fetchable cache copy.
// Replicated updates carry the writer's authoritative stamp.
if !isFromOtherCluster && oldEntry.Remote != nil && entry.Remote != nil &&
oldEntry.Remote.LastLocalSyncTsNs == entry.Remote.LastLocalSyncTsNs &&
!chunksEqual(oldEntry.Chunks, entry.Chunks) {
entry.Remote = proto.Clone(entry.Remote).(*filer_pb.RemoteEntry)
entry.Remote.LastLocalSyncTsNs = 0
}
}
if entry.Attr.Atime.IsZero() {
entry.Attr.Atime = entryInitialAtime(entry.Attr)
@@ -618,7 +589,7 @@ func (f *Filer) doListDirectoryEntries(ctx context.Context, p util.FullPath, sta
lastFileName, err = f.Store.ListDirectoryPrefixedEntries(ctx, p, startFileName, inclusive, limit, prefix, func(entry *Entry) (bool, error) {
select {
case <-ctx.Done():
glog.V(1).InfofCtx(ctx, "listing %q canceled: %v", p, ctx.Err())
glog.Errorf("Context is done.")
return false, fmt.Errorf("context canceled: %w", ctx.Err())
default:
if entry.TtlSec > 0 && !entry.IsDirectory() {
+1 -205
View File
@@ -13,7 +13,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
@@ -97,7 +96,7 @@ func newFilerWithFakeMaster(t *testing.T) (*Filer, *hookedStore, *collectionDele
mc := wdclient.NewMasterClient(
grpc.WithTransportCredentials(insecure.NewCredentials()),
"", cluster.FilerType, pb.ServerAddress("localhost:0"), "", "",
"test", cluster.FilerType, pb.ServerAddress("localhost:0"), "", "",
*pb.NewServiceDiscoveryFromMap(map[string]pb.ServerAddress{"m": masterAddress}),
)
@@ -182,206 +181,3 @@ func TestDeleteEntryMetaAndDataDeletesCollectionWhenTheRequestIsCancelledMidDele
t.Error("the bucket entry survived the delete")
}
}
func seedBucket(t *testing.T, store *hookedStore, path util.FullPath) {
t.Helper()
if err := store.InsertEntry(context.Background(), &Entry{
FullPath: path,
Attr: Attr{Mode: os.ModeDir | 0755},
}); err != nil {
t.Fatalf("seed bucket %s: %v", path, err)
}
}
// Two buckets resolving to one collection: deleting either must leave the
// collection for the other. Previously the delete dropped the collection
// named after the bucket regardless of where its data actually lived.
func TestDeleteBucketKeepsSharedCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets",
Collection: "shared",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
seedBucket(t, store, util.FullPath("/buckets/b"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("shared collection was deleted: %q", call.name)
default:
}
if store.getEntry("/buckets/b") == nil {
t.Error("the surviving bucket's entry is gone")
}
}
// A bucket named after a collection other buckets resolve to is still just a
// bucket: deleting it must not take the shared collection down with it.
func TestDeleteBucketNamedAfterSharedCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets",
Collection: "shared",
})
seedBucket(t, store, util.FullPath("/buckets/shared"))
seedBucket(t, store, util.FullPath("/buckets/other"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/shared", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection backing other buckets was deleted: %q", call.name)
default:
}
}
// A rule pointing a non-bucket path at the same collection keeps it: the
// collection serves files the bucket delete must not orphan.
func TestDeleteBucketKeepsCollectionUsedByNonBucketPath(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/a",
Collection: "cold",
})
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/archives",
Collection: "cold",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection used by /archives was deleted: %q", call.name)
default:
}
}
// A broad prefix rule covering the whole tree keeps the collection even for a
// lone bucket: the same collection backs non-bucket paths too.
func TestDeleteBucketKeepsCollectionFromBroadRule(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/",
Collection: "everything",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection from a / rule was deleted: %q", call.name)
default:
}
}
// A bucket resolving to the filer's default collection keeps it: rule-less
// writes outside buckets land there too, so it is never one bucket's alone.
func TestDeleteBucketKeepsDefaultCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.metaLogCollection = "everything"
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/a",
Collection: "everything",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("the filer's default collection was deleted: %q", call.name)
default:
}
}
// A rule nested under a surviving bucket keeps the collection: the other
// bucket resolves elsewhere at its root, but objects deeper inside it still
// land in the shared collection.
func TestDeleteBucketKeepsCollectionFromNestedSiblingRule(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/a",
Collection: "shared",
})
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/b/deep",
Collection: "shared",
})
seedBucket(t, store, util.FullPath("/buckets/a"))
seedBucket(t, store, util.FullPath("/buckets/b"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
t.Fatalf("collection used under /buckets/b/deep was deleted: %q", call.name)
default:
}
}
// A grouped gateway writes to <group>_<bucket> regardless of the storage
// rules, so that is the collection the delete must drop -- and a rule-named
// collection the bucket never used must survive.
func TestDeleteBucketUnderFilerGroup(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.MasterClient.FilerGroup = "tenant1"
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/photos",
Collection: "archive",
})
seedBucket(t, store, util.FullPath("/buckets/photos"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/photos", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
if call.name != "tenant1_photos" {
t.Fatalf("CollectionDelete = %q, want %q", call.name, "tenant1_photos")
}
case <-time.After(20 * time.Second):
t.Fatal("CollectionDelete never reached the master")
}
}
// A collection only the deleted bucket resolves to is dropped under its real
// name, so a dedicated custom collection does not leak its volumes.
func TestDeleteBucketDeletesResolvedCollection(t *testing.T) {
f, store, master := newFilerWithFakeMaster(t)
f.FilerConf.SetLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/buckets/only",
Collection: "custom",
})
seedBucket(t, store, util.FullPath("/buckets/only"))
if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/only", true, false, true, false, nil, 0); err != nil {
t.Fatalf("DeleteEntryMetaAndData: %v", err)
}
select {
case call := <-master.calls:
if call.name != "custom" {
t.Fatalf("CollectionDelete = %q, want %q", call.name, "custom")
}
case <-time.After(20 * time.Second):
t.Fatal("CollectionDelete never reached the master")
}
}
+9 -98
View File
@@ -68,10 +68,6 @@ func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isR
return nil
}
isDeleteCollection := f.IsBucket(entry)
collectionName := ""
if isDeleteCollection {
collectionName = f.bucketCollection(ctx, entry.Name())
}
if entry.IsDirectory() {
// delete the folder children, not including the folder itself
err = f.doBatchDeleteFolderMetaAndData(ctx, entry, isRecursive, ignoreRecursiveError, shouldDeleteChunks && !isDeleteCollection, isDeleteCollection, isFromOtherCluster, signatures, func(hardLinkIds []HardLinkId) error {
@@ -106,18 +102,17 @@ func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isR
}
if isDeleteCollection {
if collectionName != "" {
// the entry is already gone: a caller that hung up must not leave the
// collection behind, so this cleanup outlives the request -- bounded all
// the same, or a master that is down parks this handler indefinitely and
// every client retry behind it parks another
collectionCtx, cancelCollection := context.WithTimeout(context.WithoutCancel(ctx), collectionDeleteTimeout)
f.DoDeleteCollection(collectionCtx, collectionName)
cancelCollection()
}
collectionName := entry.Name()
// the entry is already gone: a caller that hung up must not leave the
// collection behind, so this cleanup outlives the request -- bounded all
// the same, or a master that is down parks this handler indefinitely and
// every client retry behind it parks another
collectionCtx, cancelCollection := context.WithTimeout(context.WithoutCancel(ctx), collectionDeleteTimeout)
f.DoDeleteCollection(collectionCtx, collectionName)
cancelCollection()
// drop bucket-labeled series held by this process; the S3 gateway
// only cleans its own registry
stats.DeleteBucketMetrics(entry.Name())
stats.DeleteBucketMetrics(collectionName)
}
return nil
@@ -125,15 +120,6 @@ func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isR
func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isDeletingBucket, isFromOtherCluster bool, signatures []int32, onHardLinkIdsFn OnHardLinkIdsFunc) (err error) {
var dirTombstoneTs int64
if isRecursive {
// Tombstone the directory before its children: when the store drops
// the subtree without listing it, or a child error aborts the walk,
// the ancestor tombstone still covers every descendant.
dirTombstoneTs = time.Now().UnixNano()
f.noteRemoteDeletion(entry.FullPath, true, dirTombstoneTs)
}
//collect all the chunks of this layer and delete them together at the end
var chunksToDelete []*filer_pb.FileChunk
lastFileName := ""
@@ -143,9 +129,6 @@ func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry
for {
entries, _, err := f.ListDirectoryEntries(ctx, entry.FullPath, lastFileName, includeLastFile, PaginationSize, "", "", "")
if err != nil {
// nothing was deleted; a leftover tombstone would hide the
// still-existing remote children
f.unnoteRemoteDeletion(entry.FullPath, dirTombstoneTs)
glog.ErrorfCtx(ctx, "list folder %s: %v", entry.FullPath, err)
return fmt.Errorf("list folder %s: %v", entry.FullPath, err)
}
@@ -157,7 +140,6 @@ func (f *Filer) doBatchDeleteFolderMetaAndData(ctx context.Context, entry *Entry
for _, sub := range entries {
lastFileName = sub.Name()
f.noteRemoteDeletion(sub.FullPath, sub.IsDirectory(), time.Now().UnixNano())
if sub.IsDirectory() {
subIsDeletingBucket := f.IsBucket(sub)
err = f.doBatchDeleteFolderMetaAndData(ctx, sub, isRecursive, ignoreRecursiveError, shouldDeleteChunks, subIsDeletingBucket, isFromOtherCluster, nil, onHardLinkIdsFn)
@@ -220,8 +202,6 @@ func (f *Filer) doDeleteEntryMetaAndData(ctx context.Context, entry *Entry, shou
}
}
f.noteRemoteDeletion(entry.FullPath, entry.IsDirectory(), time.Now().UnixNano())
if storeDeletionErr := f.Store.DeleteOneEntry(ctx, entry); storeDeletionErr != nil {
return fmt.Errorf("filer store delete: %w", storeDeletionErr)
}
@@ -241,75 +221,6 @@ func (f *Filer) doDeleteEntryMetaAndData(ctx context.Context, entry *Entry, shou
// DeleteCollection, still has retry budget left.
const collectionDeleteTimeout = 15 * time.Second
// bucketCollection resolves the collection a bucket's objects land in
// through the same chain the write path uses -- a grouped gateway's explicit
// collection, then the storage rules, then the bucket name -- and reports it
// only when nothing outside the bucket can still route into it. A shared
// collection must survive the bucket delete: dropping it removes volumes
// other paths still write to. A listing failure keeps the collection, the
// safe side of an unknown.
func (f *Filer) bucketCollection(ctx context.Context, bucket string) (collection string) {
bucketDir := f.DirBucketsPath + "/" + bucket + "/"
resolve := func(dir, name string) string {
if f.MasterClient != nil {
if group := f.MasterClient.FilerGroup; group != "" {
return group + "_" + name
}
}
return util.Nvl(f.FilerConf.MatchStorageRule(dir).Collection, name)
}
collection = resolve(bucketDir, bucket)
// Rule-less writes outside buckets fall back to the filer's default
// collection, so a bucket resolving there shares it with them.
if collection == f.metaLogCollection {
return ""
}
// A rule whose prefix escapes the bucket can route other paths into the
// same collection, including prefixes nested under surviving buckets.
for _, rule := range f.FilerConf.ToProto().Locations {
prefix := strings.TrimSuffix(rule.LocationPrefix, "/") + "/"
if strings.HasPrefix(prefix, bucketDir) {
continue
}
if f.FilerConf.MatchStorageRule(prefix).Collection == collection {
return ""
}
}
siblings, err := f.listBuckets(ctx)
if err != nil {
glog.ErrorfCtx(ctx, "list buckets for collection check: %v", err)
return ""
}
for _, sibling := range siblings {
if sibling != bucket && resolve(f.DirBucketsPath+"/"+sibling+"/", sibling) == collection {
return ""
}
}
return collection
}
func (f *Filer) listBuckets(ctx context.Context) (buckets []string, err error) {
lastFileName := ""
for {
entries, _, listErr := f.ListDirectoryEntries(ctx, util.FullPath(f.DirBucketsPath), lastFileName, false, PaginationSize, "", "", "")
if listErr != nil {
return nil, listErr
}
for _, entry := range entries {
lastFileName = entry.Name()
if f.IsBucket(entry) {
buckets = append(buckets, entry.Name())
}
}
if len(entries) < PaginationSize {
return buckets, nil
}
}
}
func (f *Filer) DoDeleteCollection(ctx context.Context, collectionName string) (err error) {
return f.MasterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
+15 -20
View File
@@ -306,29 +306,24 @@ func (f *Filer) loopProcessingDeletion() {
glog.V(0).Infof("deletion processor shutting down")
return
case <-ticker.C:
f.FlushFileIdDeletionQueue(context.Background(), lookupFunc)
f.FileIdDeletionQueue.Consume(func(fileIds []string) {
for i := 0; i < len(fileIds); i += DeletionBatchSize {
end := i + DeletionBatchSize
if end > len(fileIds) {
end = len(fileIds)
}
toDeleteFileIds := fileIds[i:end]
f.processDeletionBatch(toDeleteFileIds, lookupFunc)
}
})
}
}
}
func (f *Filer) FlushFileIdDeletionQueue(ctx context.Context, lookupFunc func([]string) (map[string]*operation.LookupResult, error)) (consumed []string) {
f.FileIdDeletionQueue.Consume(func(fileIds []string) {
consumed = fileIds
for i := 0; i < len(fileIds); i += DeletionBatchSize {
end := i + DeletionBatchSize
if end > len(fileIds) {
end = len(fileIds)
}
f.processDeletionBatch(ctx, fileIds[i:end], lookupFunc)
}
})
return consumed
}
// processDeletionBatch handles deletion of a batch of file IDs and processes results.
// It classifies errors into retryable and permanent categories, adds retryable failures
// to the retry queue, and logs appropriate messages.
func (f *Filer) processDeletionBatch(ctx context.Context, toDeleteFileIds []string, lookupFunc func([]string) (map[string]*operation.LookupResult, error)) {
func (f *Filer) processDeletionBatch(toDeleteFileIds []string, lookupFunc func([]string) (map[string]*operation.LookupResult, error)) {
// Deduplicate file IDs to prevent incorrect retry count increments for the same file ID within a single batch.
uniqueFileIdsSlice := make([]string, 0, len(toDeleteFileIds))
processed := make(map[string]struct{}, len(toDeleteFileIds))
@@ -344,7 +339,7 @@ func (f *Filer) processDeletionBatch(ctx context.Context, toDeleteFileIds []stri
}
// Delete files and classify outcomes
outcomes := deleteFilesAndClassify(ctx, f.GrpcDialOption, uniqueFileIdsSlice, lookupFunc)
outcomes := deleteFilesAndClassify(f.GrpcDialOption, uniqueFileIdsSlice, lookupFunc)
// Process outcomes
var successCount, notFoundCount, retryableErrorCount, permanentErrorCount int
@@ -410,9 +405,9 @@ type deletionOutcome struct {
}
// deleteFilesAndClassify performs deletion and classifies outcomes for a list of file IDs
func deleteFilesAndClassify(ctx context.Context, grpcDialOption grpc.DialOption, fileIds []string, lookupFunc func([]string) (map[string]*operation.LookupResult, error)) map[string]deletionOutcome {
func deleteFilesAndClassify(grpcDialOption grpc.DialOption, fileIds []string, lookupFunc func([]string) (map[string]*operation.LookupResult, error)) map[string]deletionOutcome {
// Perform deletion
results := operation.DeleteFileIdsWithLookupVolumeId(ctx, grpcDialOption, fileIds, lookupFunc)
results := operation.DeleteFileIdsWithLookupVolumeId(grpcDialOption, fileIds, lookupFunc)
// Group results by file ID to handle multiple results for replicated volumes
resultsByFileId := make(map[string][]*volume_server_pb.DeleteResult)
@@ -550,7 +545,7 @@ func (f *Filer) processRetryBatch(readyItems []*DeletionRetryItem, lookupFunc fu
}
// Delete files and classify outcomes
outcomes := deleteFilesAndClassify(context.Background(), f.GrpcDialOption, fileIds, lookupFunc)
outcomes := deleteFilesAndClassify(f.GrpcDialOption, fileIds, lookupFunc)
// Process outcomes - iterate over readyItems to ensure all items are accounted for
var successCount, notFoundCount, retryCount, permanentErrorCount int
+3 -56
View File
@@ -2,13 +2,11 @@ package filer
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -103,57 +101,6 @@ func TestCreateEntryAssignsInodesToAutoCreatedParents(t *testing.T) {
}
}
func TestCreateEntryOExclPreservesExistingEntry(t *testing.T) {
f, store := newTestFilerWithStubStore()
original := &Entry{
FullPath: util.FullPath("/buckets/my-bucket"),
Attr: Attr{Mode: os.ModeDir | 0o777},
Extended: map[string][]byte{
"lifecycle": []byte(`<LifecycleConfiguration/>`),
"owner": []byte("alice"),
},
}
require.NoError(t, store.InsertEntry(context.Background(), original))
replacement := &Entry{
FullPath: util.FullPath("/buckets/my-bucket"),
Attr: Attr{Mode: os.ModeDir | 0o777},
}
err := f.CreateEntry(context.Background(), replacement, original, true, false, nil, false, f.MaxFilenameLength)
require.ErrorIs(t, err, filer_pb.ErrEntryAlreadyExists)
stored, findErr := store.FindEntry(context.Background(), original.FullPath)
require.NoError(t, findErr)
assert.Equal(t, original.Extended, stored.Extended)
}
func TestCreateEntryOExclFailsOnLookupError(t *testing.T) {
f, store := newTestFilerWithStubStore()
original := &Entry{
FullPath: util.FullPath("/buckets/my-bucket"),
Attr: Attr{Mode: os.ModeDir | 0o777},
Extended: map[string][]byte{"owner": []byte("alice")},
}
require.NoError(t, store.InsertEntry(context.Background(), original))
// a failed lookup must not masquerade as "not found": without the check
// the insert path would upsert over the stored bucket entry
store.findErr = errors.New("transient store failure")
err := f.CreateEntry(context.Background(), &Entry{
FullPath: util.FullPath("/buckets/my-bucket"),
Attr: Attr{Mode: os.ModeDir | 0o777},
}, nil, true, false, nil, false, f.MaxFilenameLength)
require.Error(t, err)
assert.NotErrorIs(t, err, filer_pb.ErrEntryAlreadyExists)
store.findErr = nil
stored, findErr := store.FindEntry(context.Background(), original.FullPath)
require.NoError(t, findErr)
assert.Equal(t, original.Extended, stored.Extended)
}
func TestUpdateEntryPreservesExistingInode(t *testing.T) {
f, store := newTestFilerWithStubStore()
@@ -173,7 +120,7 @@ func TestUpdateEntryPreservesExistingInode(t *testing.T) {
},
}
err := f.UpdateEntry(context.Background(), original, updated, false)
err := f.UpdateEntry(context.Background(), original, updated)
require.Error(t, err)
updated = &Entry{
@@ -182,7 +129,7 @@ func TestUpdateEntryPreservesExistingInode(t *testing.T) {
Mode: 0o600,
},
}
err = f.UpdateEntry(context.Background(), original, updated, false)
err = f.UpdateEntry(context.Background(), original, updated)
require.NoError(t, err)
stored, findErr := store.FindEntry(context.Background(), original.FullPath)
@@ -208,7 +155,7 @@ func TestUpdateEntryBackfillsMissingLegacyInode(t *testing.T) {
Mode: 0o640,
},
}
err := f.UpdateEntry(context.Background(), original, updated, false)
err := f.UpdateEntry(context.Background(), original, updated)
require.NoError(t, err)
stored, findErr := store.FindEntry(context.Background(), original.FullPath)
-54
View File
@@ -7,10 +7,7 @@ import (
"strings"
"time"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/seaweedfs/seaweedfs/weed/util"
@@ -46,21 +43,6 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
return nil, nil
}
// A startup tombstone rebuild may still be replaying the meta log; wait
// for it so a pending delete cannot resurrect here.
if done := f.remoteTombstonesDone.Load(); done != nil {
select {
case <-*done:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if f.isRemoteDeletionPending(ctx, p, mountDir) {
glog.V(2).InfofCtx(ctx, "maybeLazyFetchFromRemote: %s deleted locally, remote delete pending", p)
return nil, nil
}
remoteConf, found := f.RemoteStorage.FindRemoteStorageConf(p)
if !found {
return nil, nil
@@ -121,27 +103,10 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
persistBaseCtx, cancelPersist := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelPersist()
persistCtx := context.WithValue(persistBaseCtx, lazyFetchContextKey{}, true)
// A delete may have landed while StatFile was in flight; re-check so
// the fetched object cannot resurrect a path whose delete is pending.
if f.isRemoteDeletionPending(persistCtx, p, mountDir) {
glog.V(2).InfofCtx(ctx, "maybeLazyFetchFromRemote: %s deleted during remote stat", p)
return lazyFetchResult{nil}, nil
}
saveErr := f.CreateEntry(persistCtx, entry, nil, false, false, nil, true, f.MaxFilenameLength)
if saveErr != nil {
glog.Warningf("maybeLazyFetchFromRemote: failed to persist filer entry for %s: %v", p, saveErr)
f.lazyFetchGroup.Forget(key)
return lazyFetchResult{entry}, nil
}
// A delete records its tombstone before removing the entry, so a
// tombstone visible now means the insert raced a delete that already
// ran: retract the persisted entry so the path stays deleted.
if f.isRemoteDeletionPending(persistCtx, p, mountDir) {
glog.V(2).InfofCtx(ctx, "maybeLazyFetchFromRemote: %s deleted while persisting", p)
f.lazyFetchGroup.Forget(key)
f.retractLazyRemoteEntry(persistCtx, entry)
return lazyFetchResult{nil}, nil
}
return lazyFetchResult{entry}, nil
@@ -157,25 +122,6 @@ func (f *Filer) maybeLazyFetchFromRemote(ctx context.Context, p util.FullPath) (
return result.entry, nil
}
// retractLazyRemoteEntry deletes the entry at entry.FullPath only when it is
// still the entry a lazy remote read just materialized — a concurrent write
// may have replaced it, and deleting by path alone would take that write down.
func (f *Filer) retractLazyRemoteEntry(ctx context.Context, entry *Entry) {
existing, findErr := f.FindEntry(ctx, entry.FullPath)
if findErr != nil || existing == nil {
return
}
// The stored entry must still be exactly what this read materialized —
// an intervening write (appended chunks, touched attributes) means a
// real update owns the path now.
if !proto.Equal(existing.ToProtoEntry(), entry.ToProtoEntry()) {
return
}
if err := f.doDeleteEntryMetaAndData(ctx, existing, false, false, nil); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
glog.Warningf("retractLazyRemoteEntry %s: %v", entry.FullPath, err)
}
}
func (f *Filer) maybeDeleteFromRemote(ctx context.Context, entry *Entry) (bool, error) {
if entry == nil || f.RemoteStorage == nil {
return false, nil
-15
View File
@@ -56,14 +56,6 @@ func (f *Filer) maybeLazyListFromRemote(ctx context.Context, p util.FullPath) {
}
}
if done := f.remoteTombstonesDone.Load(); done != nil {
select {
case <-*done:
case <-ctx.Done():
return
}
}
// Lazy listing is opt-in: disabled when TTL is 0
if remoteLoc.ListingCacheTtlSeconds <= 0 {
return
@@ -117,10 +109,6 @@ func (f *Filer) maybeLazyListFromRemote(ctx context.Context, p util.FullPath) {
return nil
}
if existingEntry == nil && f.isRemoteDeletionPending(persistCtx, childPath, mountDir) {
return nil
}
if existingEntry != nil {
// Merge: update remote metadata while preserving local state
// (Chunks, Extended, Uid/Gid/Mode, etc.)
@@ -173,9 +161,6 @@ func (f *Filer) maybeLazyListFromRemote(ctx context.Context, p util.FullPath) {
}
if saveErr := f.CreateEntry(persistCtx, entry, nil, false, false, nil, true, f.MaxFilenameLength); saveErr != nil {
glog.Warningf("maybeLazyListFromRemote: persist %s: %v", childPath, saveErr)
} else if f.isRemoteDeletionPending(persistCtx, childPath, mountDir) {
// a delete landed between the check above and the insert
f.retractLazyRemoteEntry(persistCtx, entry)
}
}
return nil
-5
View File
@@ -34,7 +34,6 @@ type stubFilerStore struct {
entries map[string]*Entry
kv map[string][]byte
insertErr error
findErr error
deleteErrByPath map[string]error
}
@@ -175,9 +174,6 @@ func (s *stubFilerStore) UpdateEntry(_ context.Context, entry *Entry) error {
func (s *stubFilerStore) FindEntry(_ context.Context, p util.FullPath) (*Entry, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.findErr != nil {
return nil, s.findErr
}
if e, ok := s.entries[string(p)]; ok {
return e, nil
}
@@ -283,7 +279,6 @@ func newTestFiler(t *testing.T, store *stubFilerStore, rs *FilerRemoteStorage) *
MasterClient: mc,
FileIdDeletionQueue: util.NewUnboundedQueue(),
deletionQuit: make(chan struct{}),
remoteTombstones: newRemoteDeletionTombstones(),
LocalMetaLogBuffer: log_buffer.NewLogBuffer("test", time.Minute,
func(*log_buffer.LogBuffer, time.Time, time.Time, []byte, int64, int64) {}, nil, func() {}),
}
-1
View File
@@ -15,7 +15,6 @@ func (f *Filer) onMetadataChangeEvent(event *filer_pb.SubscribeMetadataResponse)
f.maybeReloadRemoteStorageConfigurationAndMapping(event)
f.onBucketEvents(event)
f.onEmptyFolderCleanupEvents(event)
f.onRemoteDeletionEvents(event)
}
func (f *Filer) onBucketEvents(event *filer_pb.SubscribeMetadataResponse) {
-20
View File
@@ -104,23 +104,3 @@ func TestExpiredFileIsDeletedOnRead(t *testing.T) {
require.ErrorIs(t, err, filer_pb.ErrNotFound,
"native TTL lookup should remove the expired metadata row")
}
func TestListDirectoryEntriesContextCanceled(t *testing.T) {
f, store := newTestFilerWithStubStore()
ctx := context.Background()
require.NoError(t, store.InsertEntry(ctx, &Entry{
FullPath: util.FullPath("/dir/file1.txt"),
Attr: Attr{Mode: 0o644},
}))
canceledCtx, cancel := context.WithCancel(ctx)
cancel()
_, _, err := f.doListDirectoryEntries(canceledCtx, util.FullPath("/dir"), "", false, 10, "", func(entry *Entry) (bool, error) {
return true, nil
})
require.Error(t, err)
assert.ErrorContains(t, err, "context canceled")
}
-89
View File
@@ -1,89 +0,0 @@
package filer
import (
"context"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func chunksEqual(a, b []*filer_pb.FileChunk) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].GetFileIdString() != b[i].GetFileIdString() ||
a[i].Size != b[i].Size ||
a[i].Offset != b[i].Offset ||
a[i].ModifiedTsNs != b[i].ModifiedTsNs {
return false
}
}
return true
}
// IsEvictableRemoteEntry reports whether an entry's local chunks are backed by
// a synchronized remote copy, mirroring the checks remote.uncache applies:
// remote-backed, chunks present, and not ahead of the remote version.
func IsEvictableRemoteEntry(entry *Entry) bool {
if entry.IsDirectory() || entry.Remote == nil {
return false
}
if entry.Remote.LastLocalSyncTsNs <= 0 || len(entry.GetChunks()) == 0 {
return false
}
if entry.Remote.LastLocalSyncTsNs < entry.Mtime.UnixNano() {
return false
}
return true
}
// ListEvictableRemoteEntries walks every mounted directory and returns
// synchronized remote entries holding local chunks, oldest cached first.
func (f *Filer) ListEvictableRemoteEntries(ctx context.Context, mounts []util.FullPath, minCacheAge time.Duration) (out []*Entry) {
cutoffNs := time.Now().UnixNano() - minCacheAge.Nanoseconds()
for _, dir := range mounts {
if err := f.collectEvictableRemoteEntries(ctx, dir, cutoffNs, &out); err != nil {
glog.WarningfCtx(ctx, "list evictable remote entries under %s: %v", dir, err)
}
}
sort.Slice(out, func(i, j int) bool {
return out[i].Remote.LastLocalSyncTsNs < out[j].Remote.LastLocalSyncTsNs
})
return out
}
func (f *Filer) collectEvictableRemoteEntries(ctx context.Context, dir util.FullPath, cutoffNs int64, out *[]*Entry) error {
startFileName := ""
for {
var subDirs []util.FullPath
lastFileName, err := f.Store.ListDirectoryEntries(ctx, dir, startFileName, false, 1024, func(entry *Entry) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
if entry.IsDirectory() {
subDirs = append(subDirs, entry.FullPath)
return true, nil
}
if entry.Remote != nil && entry.Remote.LastLocalSyncTsNs > 0 && entry.Remote.LastLocalSyncTsNs <= cutoffNs && IsEvictableRemoteEntry(entry) {
*out = append(*out, entry)
}
return true, nil
})
if err != nil {
return err
}
for _, subDir := range subDirs {
if err := f.collectEvictableRemoteEntries(ctx, subDir, cutoffNs, out); err != nil {
return err
}
}
if lastFileName == "" {
return nil
}
startFileName = lastFileName
}
}
-164
View File
@@ -1,164 +0,0 @@
package filer
import (
"context"
"os"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func remoteCachedEntry(path string, syncTsNs int64, mtime time.Time, chunks int) *Entry {
entry := &Entry{
FullPath: util.FullPath(path),
Attr: Attr{
Mtime: mtime,
Crtime: mtime,
Mode: 0644,
Uid: 1,
Gid: 1,
Mime: "application/octet-stream",
Md5: nil,
FileSize: 0,
},
Remote: &filer_pb.RemoteEntry{
RemoteMtime: mtime.Unix(),
LastLocalSyncTsNs: syncTsNs,
RemoteETag: "etag",
RemoteSize: 100,
},
}
for i := 0; i < chunks; i++ {
entry.Chunks = append(entry.Chunks, &filer_pb.FileChunk{FileId: "1,01637037d6", Size: 100})
}
return entry
}
func TestIsEvictableRemoteEntry(t *testing.T) {
now := time.Now()
synced := now.Add(-time.Hour).UnixNano()
mtime := now.Add(-time.Hour)
tests := []struct {
name string
entry *Entry
expected bool
}{
{"directory", &Entry{FullPath: "/d", Attr: Attr{Mode: os.ModeDir | 0755}}, false},
{"local only, no remote", &Entry{FullPath: "/f", Attr: Attr{Mtime: mtime, Mode: 0644}}, false},
{"remote only, never cached", remoteCachedEntry("/f", 0, mtime, 0), false},
{"remote, chunks cleared", remoteCachedEntry("/f", synced, mtime, 0), false},
{"dirty, newer than remote", remoteCachedEntry("/f", synced, now, 1), false},
{"same-second write after sync", remoteCachedEntry("/f",
now.Truncate(time.Second).Add(100*time.Millisecond).UnixNano(),
now.Truncate(time.Second).Add(900*time.Millisecond), 1), false},
{"same-second sync after write", remoteCachedEntry("/f",
now.Truncate(time.Second).Add(900*time.Millisecond).UnixNano(),
now.Truncate(time.Second).Add(100*time.Millisecond), 1), true},
{"synced and cached", remoteCachedEntry("/f", synced, mtime, 1), true},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, IsEvictableRemoteEntry(tt.entry), tt.name)
}
}
func TestListEvictableRemoteEntries(t *testing.T) {
now := time.Now()
mtime := now.Add(-2 * time.Hour)
oldSync := now.Add(-time.Hour).UnixNano()
newSync := now.Add(-time.Second).UnixNano()
store := newStubFilerStore()
f := newTestFiler(t, store, NewFilerRemoteStorage())
mounts := []util.FullPath{"/buckets/mybucket"}
seed := func(e *Entry) {
require.NoError(t, f.CreateEntry(context.Background(), e, nil, false, false, nil, false, 255))
}
seed(remoteCachedEntry("/buckets/mybucket/old.bin", oldSync, mtime, 1))
seed(remoteCachedEntry("/buckets/mybucket/fresh.bin", newSync, mtime, 1))
seed(remoteCachedEntry("/buckets/mybucket/remoteonly.bin", 0, mtime, 0))
seed(remoteCachedEntry("/buckets/mybucket/dirty.bin", oldSync, now, 1))
seed(&Entry{FullPath: "/buckets/mybucket/plain.bin", Attr: Attr{Mtime: mtime, Mode: 0644}})
store.entries["/buckets/mybucket/sub"] = &Entry{
FullPath: "/buckets/mybucket/sub",
Attr: Attr{Mode: os.ModeDir | 0755, Mtime: mtime},
}
seed(remoteCachedEntry("/buckets/mybucket/sub/nested.bin", oldSync-1000, mtime, 1))
got := f.ListEvictableRemoteEntries(context.Background(), mounts, 0)
var paths []string
for _, e := range got {
paths = append(paths, string(e.FullPath))
}
assert.Equal(t, []string{
"/buckets/mybucket/sub/nested.bin",
"/buckets/mybucket/old.bin",
"/buckets/mybucket/fresh.bin",
}, paths, "oldest LastLocalSyncTsNs first; remote-only, dirty, local entries skipped")
got = f.ListEvictableRemoteEntries(context.Background(), mounts, 30*time.Second)
paths = paths[:0]
for _, e := range got {
paths = append(paths, string(e.FullPath))
}
assert.Equal(t, []string{
"/buckets/mybucket/sub/nested.bin",
"/buckets/mybucket/old.bin",
}, paths, "minCacheAge excludes freshly cached entries")
}
func TestUpdateEntryInvalidatesStaleSyncStamp(t *testing.T) {
now := time.Now()
synced := now.Add(-time.Hour).UnixNano()
mtime := now.Add(-time.Hour)
path := "/buckets/mybucket/a.bin"
newFiler := func() *Filer {
f := newTestFiler(t, newStubFilerStore(), NewFilerRemoteStorage())
require.NoError(t, f.CreateEntry(context.Background(), remoteCachedEntry(path, synced, mtime, 1), nil, false, false, nil, false, 255))
return f
}
syncStamp := func(f *Filer) int64 {
entry, err := f.FindEntry(context.Background(), util.FullPath(path))
require.NoError(t, err)
return entry.Remote.LastLocalSyncTsNs
}
t.Run("local chunk change clears stamp", func(t *testing.T) {
f := newFiler()
update := remoteCachedEntry(path, synced, mtime, 1)
update.Chunks[0].FileId = "2,01637037d6"
require.NoError(t, f.CreateEntry(context.Background(), update, nil, false, false, nil, false, 255))
assert.Zero(t, syncStamp(f))
})
t.Run("metadata-only update keeps stamp", func(t *testing.T) {
f := newFiler()
update := remoteCachedEntry(path, synced, mtime, 1)
update.Attr.Mime = "text/plain"
require.NoError(t, f.CreateEntry(context.Background(), update, nil, false, false, nil, false, 255))
assert.Equal(t, synced, syncStamp(f))
})
t.Run("fresh sync stamp survives chunk change", func(t *testing.T) {
f := newFiler()
update := remoteCachedEntry(path, now.UnixNano(), mtime, 1)
update.Chunks[0].FileId = "2,01637037d6"
require.NoError(t, f.CreateEntry(context.Background(), update, nil, false, false, nil, false, 255))
assert.Equal(t, now.UnixNano(), syncStamp(f))
})
t.Run("replicated update keeps authoritative stamp", func(t *testing.T) {
f := newFiler()
update := remoteCachedEntry(path, synced, mtime, 1)
update.Chunks[0].FileId = "2,01637037d6"
require.NoError(t, f.CreateEntry(context.Background(), update, nil, false, true, nil, false, 255))
assert.Equal(t, synced, syncStamp(f))
})
}
-423
View File
@@ -1,423 +0,0 @@
package filer
import (
"context"
"math"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
"google.golang.org/protobuf/proto"
)
const (
// remoteDeletionTombstoneTTL bounds a tombstone when no write-back sync
// offset ever confirms the remote delete. A remote object re-created
// outside the filer at a deleted path stays hidden for this long.
remoteDeletionTombstoneTTL = 24 * time.Hour
// remoteDeletionTombstoneLimit bounds tracked paths; past it new
// tombstones are dropped after an expired sweep still leaves no room.
remoteDeletionTombstoneLimit = 1 << 16
)
// remoteDeletionTombstones tracks paths deleted under a remote mount whose
// remote object may still exist because the write-back daemon has not
// consumed the delete event yet. A lazy remote fetch or listing must not
// resurrect them. A tombstone lifts when the path is written again, when the
// mount's persisted sync offset passes the delete event, or on TTL.
//
// Tombstones recorded before their metadata event lands (the synchronous
// delete path) are marked pending: the sync offset orders against event
// timestamps, which only the event itself knows, so a pending tombstone can
// only be lifted by the event confirming it or by TTL. Once the event stamps
// the real timestamp the tombstone is releasable by the offset.
type remoteDeletionTombstones struct {
mu sync.Mutex
files map[string]int64 // file path -> delete event TsNs
dirs map[string]int64 // deleted directory path -> event TsNs; covers its subtree
pending map[string]bool // tombstone path recorded ahead of its event
}
func newRemoteDeletionTombstones() *remoteDeletionTombstones {
return &remoteDeletionTombstones{
files: make(map[string]int64),
dirs: make(map[string]int64),
pending: make(map[string]bool),
}
}
// add records a tombstone ahead of its metadata event — the timestamp is the
// local delete time, a lower bound the sync offset cannot order against.
func (t *remoteDeletionTombstones) add(path string, isDir bool, tsNs int64) {
t.upsert(path, isDir, tsNs, false)
}
// addFromEvent records a tombstone stamped by the delete event itself, so the
// mount's sync offset can release it once the daemon passes that event.
func (t *remoteDeletionTombstones) addFromEvent(path string, isDir bool, tsNs int64) {
t.upsert(path, isDir, tsNs, true)
}
func (t *remoteDeletionTombstones) upsert(path string, isDir bool, tsNs int64, fromEvent bool) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
// An ancestor directory tombstone at least as new already covers the
// path; recording it again only spends capacity.
for p := path; ; {
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
if ancestorTs, ok := t.dirs[p]; ok && ancestorTs >= tsNs {
return
}
}
m := t.files
if isDir {
m = t.dirs
}
if cur, ok := m[path]; ok {
if tsNs > cur {
m[path] = tsNs
if !fromEvent {
// a newer local delete restamps the tombstone ahead of its
// event — the offset cannot vouch for it until the event lands
t.pending[path] = true
}
}
if fromEvent && m[path] == tsNs {
delete(t.pending, path)
}
return
}
if len(t.files)+len(t.dirs) >= remoteDeletionTombstoneLimit {
t.evictExpiredLocked(time.Now().UnixNano())
if len(t.files)+len(t.dirs) >= remoteDeletionTombstoneLimit {
glog.V(0).Infof("remote deletion tombstones full (%d), skipping %s", remoteDeletionTombstoneLimit, path)
return
}
}
m[path] = tsNs
if fromEvent {
delete(t.pending, path)
} else {
t.pending[path] = true
}
if isDir {
t.dropCoveredLocked(path, tsNs)
}
}
// dropCoveredLocked removes descendant tombstones a new directory tombstone
// subsumes: their deletes predate it, so the ancestor already hides those
// remote objects. Descendants deleted later keep their own tombstone.
// Caller must hold t.mu.
func (t *remoteDeletionTombstones) dropCoveredLocked(dirPath string, tsNs int64) {
prefix := dirPath + "/"
for p, ts := range t.files {
if ts <= tsNs && strings.HasPrefix(p, prefix) {
delete(t.files, p)
delete(t.pending, p)
}
}
for p, ts := range t.dirs {
if ts <= tsNs && strings.HasPrefix(p, prefix) {
delete(t.dirs, p)
delete(t.pending, p)
}
}
}
// drop removes the exact tombstone recorded for path, e.g. when the delete
// that recorded it fails before touching anything.
func (t *remoteDeletionTombstones) drop(path string, tsNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.dirs[path]; ok && cur <= tsNs {
delete(t.dirs, path)
delete(t.pending, path)
}
if cur, ok := t.files[path]; ok && cur <= tsNs {
delete(t.files, path)
delete(t.pending, path)
}
}
// clear drops a file tombstone when a write at the path is at least as new as
// the delete; a replayed older create must not lift a newer delete.
func (t *remoteDeletionTombstones) clear(path string, tsNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.files[path]; ok && tsNs >= cur {
delete(t.files, path)
delete(t.pending, path)
}
}
// blockedSince returns the newest delete timestamp governing path — its own
// file tombstone or one from a deleted ancestor directory — and whether that
// tombstone is still waiting for its event. 0 means clear.
func (t *remoteDeletionTombstones) blockedSince(path string) (tsNs int64, pending bool) {
if t == nil {
return 0, false
}
t.mu.Lock()
defer t.mu.Unlock()
if ts, ok := t.files[path]; ok {
tsNs = ts
pending = t.pending[path]
}
for p := path; ; {
if ts, ok := t.dirs[p]; ok && ts > tsNs {
tsNs = ts
pending = t.pending[p]
}
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
}
return tsNs, pending
}
// releaseThrough drops the tombstones governing path that are no newer than
// tsNs, once their remote deletes are confirmed consumed.
func (t *remoteDeletionTombstones) releaseThrough(path string, tsNs int64) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if cur, ok := t.files[path]; ok && cur <= tsNs {
delete(t.files, path)
delete(t.pending, path)
}
for p := path; ; {
if cur, ok := t.dirs[p]; ok && cur <= tsNs {
delete(t.dirs, p)
delete(t.pending, p)
}
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
}
}
func (t *remoteDeletionTombstones) evictExpiredLocked(nowNs int64) {
for p, ts := range t.files {
if nowNs-ts >= int64(remoteDeletionTombstoneTTL) {
delete(t.files, p)
delete(t.pending, p)
}
}
for p, ts := range t.dirs {
if nowNs-ts >= int64(remoteDeletionTombstoneTTL) {
delete(t.dirs, p)
delete(t.pending, p)
}
}
}
// noteRemoteDeletion records a delete of path under a remote mount so lazy
// remote reads skip it until the remote delete is confirmed.
func (f *Filer) noteRemoteDeletion(p util.FullPath, isDir bool, tsNs int64) {
if f.RemoteStorage == nil || f.remoteTombstones == nil {
return
}
if _, remoteLoc := f.RemoteStorage.FindMountDirectory(p); remoteLoc == nil {
return
}
f.remoteTombstones.add(string(p), isDir, tsNs)
}
// unnoteRemoteDeletion retracts a tombstone when the delete that recorded it
// fails before touching anything under path.
func (f *Filer) unnoteRemoteDeletion(p util.FullPath, tsNs int64) {
if f.remoteTombstones == nil {
return
}
f.remoteTombstones.drop(string(p), tsNs)
}
// isRemoteDeletionPending reports whether a remote write-back delete for p is
// still owed: p was deleted under mountDir and neither a rewrite, the mount's
// sync offset, nor the TTL has lifted the tombstone.
func (f *Filer) isRemoteDeletionPending(ctx context.Context, p util.FullPath, mountDir util.FullPath) bool {
if f.remoteTombstones == nil {
return false
}
tsNs, pending := f.remoteTombstones.blockedSince(string(p))
if tsNs == 0 {
return false
}
if pending {
// Recorded ahead of its delete event — the write-back offset cannot
// vouch for it yet; only the TTL lifts it.
if f.remoteDeletionExpired(tsNs) {
f.remoteTombstones.releaseThrough(string(p), tsNs)
return false
}
return true
}
if f.remoteDeletionConsumed(ctx, mountDir, tsNs) {
f.remoteTombstones.releaseThrough(string(p), tsNs)
return false
}
return true
}
func (f *Filer) remoteDeletionExpired(tsNs int64) bool {
return time.Now().UnixNano()-tsNs >= int64(remoteDeletionTombstoneTTL)
}
func (f *Filer) remoteDeletionConsumed(ctx context.Context, mountDir util.FullPath, tsNs int64) bool {
if f.remoteDeletionExpired(tsNs) {
return true
}
offset, err := f.readRemoteSyncOffset(ctx, mountDir)
return err == nil && offset >= tsNs
}
// readRemoteSyncOffset reads the write-back daemon's persisted watermark for
// mountDir straight from the local store: every event at or below it has been
// applied to the remote.
func (f *Filer) readRemoteSyncOffset(ctx context.Context, mountDir util.FullPath) (int64, error) {
value, err := f.Store.KvGet(ctx, remote_storage.SyncOffsetKey(string(mountDir)))
if err != nil {
return 0, err
}
if len(value) < 8 {
return 0, nil
}
return int64(util.BytesToUint64(value)), nil
}
// RebuildRemoteDeletionTombstones gates lazy remote reads and replays the
// persisted metadata log from the oldest write-back offset across mounts,
// restoring tombstones for deletes committed before a restart but not yet
// applied to the remote. Every filer writes its log files under the same
// directory, so pending peer deletes replay too; only events still inside
// the unflushed buffer tail are missed. The gate is set synchronously so no
// lazy read can slip in before replay starts, and it stays closed until a
// replay succeeds.
func (f *Filer) RebuildRemoteDeletionTombstones(ctx context.Context) {
if f.RemoteStorage == nil || f.remoteTombstones == nil {
return
}
mounts := f.RemoteStorage.MountedDirectories()
if len(mounts) == 0 {
return
}
done := make(chan struct{})
f.remoteTombstonesDone.Store(&done)
go f.rebuildRemoteDeletionTombstones(ctx, mounts, done)
}
func (f *Filer) rebuildRemoteDeletionTombstones(ctx context.Context, mounts []util.FullPath, done chan struct{}) {
// the replay itself lists directories; do not let it wait on its own gate
ctx = context.WithValue(ctx, lazyFetchContextKey{}, true)
startTsNs := f.remoteDeletionRebuildStartTsNs(ctx, mounts)
openGate := func() {
close(done)
f.remoteTombstonesDone.Store(nil)
}
// Deletes made after startup are recorded through the live delete and
// event paths, so replay can only be missing deletes committed before
// the restart — and those have all crossed the tombstone TTL once this
// deadline passes. Holding the gate longer protects nothing.
replayDeadline := time.Now().Add(remoteDeletionTombstoneTTL)
backoff := 2 * time.Second
for {
_, _, err := f.ReadPersistedLogBuffer(ctx, log_buffer.NewMessagePosition(startTsNs, 0), 0,
func(logEntry *filer_pb.LogEntry) (bool, error) {
event := &filer_pb.SubscribeMetadataResponse{}
if err := proto.Unmarshal(logEntry.Data, event); err != nil {
return false, nil
}
f.onRemoteDeletionEvents(event)
return false, nil
})
if err == nil {
openGate()
return
}
glog.WarningfCtx(ctx, "rebuild remote deletion tombstones: %v", err)
if !time.Now().Before(replayDeadline) {
glog.ErrorfCtx(ctx, "rebuild remote deletion tombstones: giving up after %v", remoteDeletionTombstoneTTL)
openGate()
return
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < time.Minute {
backoff *= 2
}
}
}
// remoteDeletionRebuildStartTsNs returns the oldest write-back offset across
// mounts — the earliest event the daemon may not have applied. Mounts without
// a recorded offset replay from the TTL floor: events older than it would
// build tombstones that are already expired.
func (f *Filer) remoteDeletionRebuildStartTsNs(ctx context.Context, mounts []util.FullPath) int64 {
startTsNs := int64(math.MaxInt64)
for _, dir := range mounts {
offset, err := f.readRemoteSyncOffset(ctx, dir)
if err != nil {
glog.WarningfCtx(ctx, "read remote sync offset for %s: %v", dir, err)
offset = 0
}
if offset < startTsNs {
startTsNs = offset
}
}
ttlFloor := time.Now().Add(-remoteDeletionTombstoneTTL).UnixNano()
if startTsNs == int64(math.MaxInt64) || startTsNs < ttlFloor {
return ttlFloor
}
return startTsNs
}
// onRemoteDeletionEvents folds peer and local metadata events into the
// tombstone set: a delete or rename source is tombstoned at the event
// timestamp, a create/update/rename target lifts a file tombstone.
func (f *Filer) onRemoteDeletionEvents(event *filer_pb.SubscribeMetadataResponse) {
message := event.EventNotification
if message == nil {
return
}
if message.OldEntry != nil {
sourcePath := filer_pb.MetadataEventSourceFullPath(event)
if message.NewEntry == nil || sourcePath != filer_pb.MetadataEventTargetFullPath(event) {
if f.RemoteStorage != nil && f.remoteTombstones != nil {
if _, remoteLoc := f.RemoteStorage.FindMountDirectory(util.FullPath(sourcePath)); remoteLoc != nil {
f.remoteTombstones.addFromEvent(sourcePath, message.OldEntry.IsDirectory, event.TsNs)
}
}
}
}
if message.NewEntry != nil {
f.remoteTombstones.clear(filer_pb.MetadataEventTargetFullPath(event), event.TsNs)
}
}
-437
View File
@@ -1,437 +0,0 @@
package filer
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newMountedTestFiler(t *testing.T, storageType string, stub remote_storage.RemoteStorageClient, listingTtlSeconds int32) (*Filer, *stubFilerStore) {
t.Helper()
if stub != nil {
t.Cleanup(registerStubMaker(t, storageType, stub))
}
conf := &remote_pb.RemoteConf{Name: "tombstonestore", Type: storageType}
rs := NewFilerRemoteStorage()
rs.storageNameToConf[conf.Name] = conf
rs.mapDirectoryToRemoteStorage("/buckets/mybucket", &remote_pb.RemoteStorageLocation{
Name: "tombstonestore",
Bucket: "mybucket",
Path: "/",
ListingCacheTtlSeconds: listingTtlSeconds,
})
store := newStubFilerStore()
return newTestFiler(t, store, rs), store
}
func putRemoteSyncOffset(t *testing.T, store *stubFilerStore, dir string, offsetTsNs int64) {
t.Helper()
buf := make([]byte, 8)
util.Uint64toBytes(buf, uint64(offsetTsNs))
require.NoError(t, store.KvPut(context.Background(), remote_storage.SyncOffsetKey(dir), buf))
}
func TestRemoteDeletionTombstones_BlocksAndReleases(t *testing.T) {
tombs := newRemoteDeletionTombstones()
tombs.addFromEvent("/m/a.txt", false, 100)
ts, _ := tombs.blockedSince("/m/a.txt")
assert.Equal(t, int64(100), ts)
ts, _ = tombs.blockedSince("/m/b.txt")
assert.Zero(t, ts)
// a delete of a directory blocks its whole subtree
tombs.addFromEvent("/m/dir", true, 200)
ts, _ = tombs.blockedSince("/m/dir")
assert.Equal(t, int64(200), ts)
ts, _ = tombs.blockedSince("/m/dir/deep/x.txt")
assert.Equal(t, int64(200), ts)
ts, _ = tombs.blockedSince("/m/dirx/y.txt")
assert.Zero(t, ts)
// a rewrite at or after the delete lifts only that file's tombstone
tombs.clear("/m/a.txt", 100)
ts, _ = tombs.blockedSince("/m/a.txt")
assert.Zero(t, ts)
tombs.addFromEvent("/m/a.txt", false, 300)
tombs.clear("/m/a.txt", 250)
ts, _ = tombs.blockedSince("/m/a.txt")
assert.Equal(t, int64(300), ts, "older create must not lift newer delete")
tombs.releaseThrough("/m/dir/deep/x.txt", 200)
ts, _ = tombs.blockedSince("/m/dir/deep/x.txt")
assert.Zero(t, ts)
ts, _ = tombs.blockedSince("/m/dir")
assert.Zero(t, ts)
}
func TestRemoteDeletionTombstones_AncestorSubsumesAndCovers(t *testing.T) {
tombs := newRemoteDeletionTombstones()
// a child tombstone recorded before its ancestor is dropped once the
// ancestor's newer delete covers the whole subtree
tombs.addFromEvent("/m/dir/a.txt", false, 100)
tombs.addFromEvent("/m/dir", true, 200)
ts, _ := tombs.blockedSince("/m/dir")
assert.Equal(t, int64(200), ts)
ts, _ = tombs.blockedSince("/m/dir/a.txt")
assert.Equal(t, int64(200), ts)
_, exists := tombs.files["/m/dir/a.txt"]
assert.False(t, exists, "descendant tombstone is subsumed by the ancestor")
// adds under the covered subtree are skipped while the ancestor stands
tombs.addFromEvent("/m/dir/b.txt", false, 150)
_, exists = tombs.files["/m/dir/b.txt"]
assert.False(t, exists)
// ...but a child deleted after the ancestor still records its own tombstone
tombs.addFromEvent("/m/dir/c.txt", false, 300)
ts, _ = tombs.blockedSince("/m/dir/c.txt")
assert.Equal(t, int64(300), ts)
}
func TestRemoteDeletionTombstones_PendingIgnoresOffset(t *testing.T) {
f, store := newMountedTestFiler(t, "stub_tomb_pending_offset", nil, 0)
// recorded before its event lands: a later event may already have moved
// the mount's watermark past the local timestamp, so the offset cannot
// vouch for this delete yet
filePath := "/buckets/mybucket/dir/a.txt"
now := time.Now().UnixNano()
f.noteRemoteDeletion(util.FullPath(filePath), false, now)
putRemoteSyncOffset(t, store, "/buckets/mybucket", now+100)
assert.True(t, f.isRemoteDeletionPending(context.Background(), util.FullPath(filePath), "/buckets/mybucket"))
// once the delete event stamps the real timestamp, the watermark releases it
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket/dir",
TsNs: now + 50,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "a.txt"},
},
})
assert.False(t, f.isRemoteDeletionPending(context.Background(), util.FullPath(filePath), "/buckets/mybucket"))
}
func TestRemoteDeletionTombstones_RaisedLocalTombstoneIsPendingAgain(t *testing.T) {
tombs := newRemoteDeletionTombstones()
tombs.addFromEvent("/m/a.txt", false, 100)
_, pending := tombs.blockedSince("/m/a.txt")
assert.False(t, pending)
// a newer local delete restamps the tombstone before its own event
// lands, so the write-back offset cannot vouch for it yet
tombs.add("/m/a.txt", false, 200)
_, pending = tombs.blockedSince("/m/a.txt")
assert.True(t, pending)
// once that delete's event confirms the new timestamp it releases normally
tombs.addFromEvent("/m/a.txt", false, 200)
_, pending = tombs.blockedSince("/m/a.txt")
assert.False(t, pending)
}
func TestMaybeLazyFetchFromRemote_SkipsTombstonedPath(t *testing.T) {
const storageType = "stub_tomb_fetch"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 11},
},
}
f, _ := newMountedTestFiler(t, storageType, stub, 0)
// a delete under the mount tombstones the path; the remote object is
// still there until the write-back daemon consumes the delete event
filePath := util.FullPath("/buckets/mybucket/dir/a.txt")
f.noteRemoteDeletion(filePath, false, time.Now().UnixNano())
entry, err := f.maybeLazyFetchFromRemote(context.Background(), filePath)
require.NoError(t, err)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls, "the remote object must not even be consulted")
}
func TestMaybeLazyFetchFromRemote_NewerRemoteMtimeStillBlocked(t *testing.T) {
const storageType = "stub_tomb_regen"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: time.Now().Unix() + 60, RemoteSize: 11},
},
}
f, _ := newMountedTestFiler(t, storageType, stub, 0)
// a remote object whose mtime postdates the delete is still hidden: the
// remote clock cannot distinguish a new generation from the pending
// delete's target
filePath := util.FullPath("/buckets/mybucket/dir/a.txt")
f.noteRemoteDeletion(filePath, false, time.Now().UnixNano())
entry, err := f.maybeLazyFetchFromRemote(context.Background(), filePath)
require.NoError(t, err)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls)
}
func TestMaybeLazyFetchFromRemote_SyncOffsetReleasesTombstone(t *testing.T) {
const storageType = "stub_tomb_release"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 11},
},
}
f, store := newMountedTestFiler(t, storageType, stub, 0)
filePath := util.FullPath("/buckets/mybucket/dir/a.txt")
deleteTsNs := time.Now().UnixNano()
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket/dir",
TsNs: deleteTsNs,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "a.txt"},
},
})
// the daemon's persisted watermark is behind the delete: still blocked
putRemoteSyncOffset(t, store, "/buckets/mybucket", deleteTsNs-1)
entry, err := f.maybeLazyFetchFromRemote(context.Background(), filePath)
require.NoError(t, err)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls)
// once the watermark reaches the delete event's own timestamp, the
// remote delete has landed and the lookup may consult the remote again
putRemoteSyncOffset(t, store, "/buckets/mybucket", deleteTsNs)
entry, err = f.maybeLazyFetchFromRemote(context.Background(), filePath)
require.NoError(t, err)
require.NotNil(t, entry)
assert.Equal(t, 1, stub.statCalls)
}
func TestDeleteEntryMetaAndData_TombstonesPath(t *testing.T) {
const storageType = "stub_tomb_delete"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 11},
},
}
f, store := newMountedTestFiler(t, storageType, stub, 0)
filePath := util.FullPath("/buckets/mybucket/dir/a.txt")
store.entries[string(filePath)] = &Entry{
FullPath: filePath,
Attr: Attr{
Mtime: time.Unix(1700000000, 0),
Crtime: time.Unix(1700000000, 0),
Mode: 0644,
FileSize: 11,
},
}
require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0))
// the deleted path must not resurrect through the lazy fetch even while
// the remote object is still present
entry, err := f.FindEntry(context.Background(), filePath)
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls)
// a peer-observed create at the path lifts the tombstone
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket/dir",
TsNs: time.Now().UnixNano(),
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "a.txt"},
},
})
entry, err = f.maybeLazyFetchFromRemote(context.Background(), filePath)
require.NoError(t, err)
require.NotNil(t, entry)
assert.Equal(t, 1, stub.statCalls)
}
func TestOnMetadataChangeEvent_PeerDeleteTombstones(t *testing.T) {
const storageType = "stub_tomb_peer"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 11},
},
}
f, _ := newMountedTestFiler(t, storageType, stub, 0)
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket/dir",
TsNs: time.Now().UnixNano(),
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "a.txt"},
},
})
entry, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/dir/a.txt")
require.NoError(t, err)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls)
}
func TestOnMetadataChangeEvent_PeerDirDeleteTombstonesSubtree(t *testing.T) {
const storageType = "stub_tomb_peer_dir"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 11},
},
}
f, _ := newMountedTestFiler(t, storageType, stub, 0)
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
TsNs: time.Now().UnixNano(),
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "dir", IsDirectory: true},
},
})
entry, err := f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/dir/deep/a.txt")
require.NoError(t, err)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls)
}
func TestMaybeLazyListFromRemote_SkipsTombstonedChild(t *testing.T) {
const storageType = "stub_tomb_list"
stub := &stubRemoteClient{
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
if err := visitFn("/", "deleted.txt", false, &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10}); err != nil {
return err
}
return visitFn("/", "fresh.txt", false, &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 20})
},
}
f, store := newMountedTestFiler(t, storageType, stub, 300)
f.noteRemoteDeletion("/buckets/mybucket/deleted.txt", false, time.Now().UnixNano())
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket"))
assert.Equal(t, 1, stub.listDirCalls)
assert.Nil(t, store.getEntry("/buckets/mybucket/deleted.txt"), "deleted child must not resurrect through a listing")
require.NotNil(t, store.getEntry("/buckets/mybucket/fresh.txt"), "other remote objects still list")
}
func TestMaybeLazyListFromRemote_RecreatedDirStillHidesChildren(t *testing.T) {
const storageType = "stub_tomb_recreate"
deleteTs := time.Now().UnixNano()
stub := &stubRemoteClient{
listDirFn: func(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) error {
if err := visitFn("/", "stale.txt", false, &filer_pb.RemoteEntry{RemoteMtime: deleteTs/int64(time.Second) - 10, RemoteSize: 10}); err != nil {
return err
}
return visitFn("/", "fresh.txt", false, &filer_pb.RemoteEntry{RemoteMtime: deleteTs/int64(time.Second) + 10, RemoteSize: 20})
},
}
f, store := newMountedTestFiler(t, storageType, stub, 300)
// the directory is deleted then recreated; its remote children stay
// hidden — old or new mtime alike — until the remote delete lands
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
TsNs: deleteTs,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "dir", IsDirectory: true},
},
})
f.onMetadataChangeEvent(&filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/mybucket",
TsNs: deleteTs + 1,
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "dir", IsDirectory: true},
},
})
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket/dir"))
assert.Nil(t, store.getEntry("/buckets/mybucket/dir/stale.txt"))
assert.Nil(t, store.getEntry("/buckets/mybucket/dir/fresh.txt"))
// after the write-back daemon confirms the remote delete, listing merges again
f.remoteTombstones.releaseThrough("/buckets/mybucket/dir/stale.txt", deleteTs)
delete(store.getEntry("/buckets/mybucket/dir").Extended, xattrRemoteListingSyncedAt)
f.maybeLazyListFromRemote(context.Background(), util.FullPath("/buckets/mybucket/dir"))
assert.NotNil(t, store.getEntry("/buckets/mybucket/dir/stale.txt"))
assert.NotNil(t, store.getEntry("/buckets/mybucket/dir/fresh.txt"))
}
func TestRemoteDeletionRebuildStartTsNs_UsesOldestMountOffset(t *testing.T) {
f, store := newMountedTestFiler(t, "stub_rebuild", nil, 0)
f.RemoteStorage.mapDirectoryToRemoteStorage("/buckets/other", &remote_pb.RemoteStorageLocation{
Name: "tombstonestore", Bucket: "other", Path: "/",
})
mounts := f.RemoteStorage.MountedDirectories()
require.Len(t, mounts, 2)
now := time.Now().UnixNano()
putRemoteSyncOffset(t, store, "/buckets/mybucket", now-200)
putRemoteSyncOffset(t, store, "/buckets/other", now-300)
assert.Equal(t, now-300, f.remoteDeletionRebuildStartTsNs(context.Background(), mounts),
"rebuild must replay from the least-synced mount")
// a mount whose offset was never written replays only within the TTL
require.NoError(t, store.KvDelete(context.Background(), remote_storage.SyncOffsetKey("/buckets/other")))
floor := f.remoteDeletionRebuildStartTsNs(context.Background(), mounts)
assert.GreaterOrEqual(t, floor, time.Now().Add(-remoteDeletionTombstoneTTL-time.Second).UnixNano())
// an offset older than the TTL floor is raised to it
putRemoteSyncOffset(t, store, "/buckets/other", time.Now().Add(-remoteDeletionTombstoneTTL-time.Hour).UnixNano())
assert.Greater(t, f.remoteDeletionRebuildStartTsNs(context.Background(), mounts),
time.Now().Add(-remoteDeletionTombstoneTTL-time.Hour).UnixNano())
}
func TestRebuildRemoteDeletionTombstones_EmptyLogReleasesGate(t *testing.T) {
f, _ := newMountedTestFiler(t, "stub_rebuild_empty", nil, 0)
f.RebuildRemoteDeletionTombstones(context.Background())
done := f.remoteTombstonesDone.Load()
require.NotNil(t, done, "the gate must be set synchronously")
select {
case <-*done:
case <-time.After(10 * time.Second):
t.Fatal("rebuild never released the gate")
}
assert.Nil(t, f.remoteTombstonesDone.Load())
ts, _ := f.remoteTombstones.blockedSince("/buckets/mybucket/a.txt")
assert.Zero(t, ts)
}
func TestMaybeLazyFetchFromRemote_WaitsForRebuild(t *testing.T) {
const storageType = "stub_tomb_pending"
stub := &countingRemoteClient{
stubRemoteClient: stubRemoteClient{
statResult: &filer_pb.RemoteEntry{RemoteMtime: 1700000000, RemoteSize: 10},
},
}
f, _ := newMountedTestFiler(t, storageType, stub, 0)
// an unfinished rebuild blocks the fetch until the context gives up
gate := make(chan struct{})
f.remoteTombstonesDone.Store(&gate)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
entry, err := f.maybeLazyFetchFromRemote(ctx, "/buckets/mybucket/a.txt")
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Nil(t, entry)
assert.Equal(t, 0, stub.statCalls)
// once the rebuild finishes, the fetch proceeds
close(gate)
entry, err = f.maybeLazyFetchFromRemote(context.Background(), "/buckets/mybucket/a.txt")
require.NoError(t, err)
require.NotNil(t, entry)
assert.Equal(t, 1, stub.statCalls)
}
+12 -22
View File
@@ -32,9 +32,7 @@ type ChunkReadAt struct {
fileSize int64
readerCache *ReaderCache
readerPattern *ReaderPattern
lastChunkMu sync.Mutex // guards lastChunkFid; mount issues concurrent ReadAt calls
lastChunkFid string
stream chunkStream // chunk this reader is positioned in, pinned in the shared readerCache
prefetchCount int // Number of chunks to prefetch ahead during sequential reads
ctx context.Context // Context used for cancellation during chunk read operations
}
@@ -169,17 +167,10 @@ func (c *ChunkReadAt) Size() int64 {
}
func (c *ChunkReadAt) Close() error {
c.ReleaseStream()
c.readerCache.destroy()
return nil
}
// ReleaseStream drops this reader's hold on the chunk it is positioned in.
// Unlike Close it leaves the (possibly shared) ReaderCache intact.
func (c *ChunkReadAt) ReleaseStream() {
c.readerCache.releaseStream(&c.stream)
}
func (c *ChunkReadAt) ReadAt(p []byte, offset int64) (n int, err error) {
c.readerPattern.MonitorReadAt(offset, len(p))
@@ -350,7 +341,6 @@ func (c *ChunkReadAt) doReadAt(ctx context.Context, p []byte, offset int64) (n i
func (c *ChunkReadAt) readChunkSliceAt(ctx context.Context, buffer []byte, chunkView *ChunkView, nextChunkViews *Interval[*ChunkView], offset uint64) (n int, err error) {
if c.readerPattern.IsRandomMode() {
c.readerCache.releaseStream(&c.stream)
n, err := c.readerCache.chunkCache.ReadChunkAt(buffer, chunkView.FileId, offset)
if n > 0 {
return n, err
@@ -360,20 +350,20 @@ func (c *ChunkReadAt) readChunkSliceAt(ctx context.Context, buffer []byte, chunk
}
shouldCache := (uint64(chunkView.ViewOffset) + chunkView.ChunkSize) <= c.readerCache.chunkCache.GetMaxFilePartSizeInCache()
// The previous chunk is released through the stream pin rather than
// UnCache: the buffer is shared, and other streams may still be reading it.
n, err = c.readerCache.readChunkAt(ctx, &c.stream, buffer, chunkView.FileId, chunkView.CipherKey, chunkView.IsGzipped, int64(offset), int(chunkView.ChunkSize), shouldCache)
c.lastChunkMu.Lock()
enteredChunk := c.lastChunkFid != chunkView.FileId
c.lastChunkFid = chunkView.FileId
c.lastChunkMu.Unlock()
if enteredChunk && chunkView.OffsetInChunk == 0 { // start of a new chunk
if nextChunkViews != nil && c.prefetchCount > 0 {
// Prefetch multiple chunks ahead for better sequential read throughput
// This keeps the network pipeline full with parallel chunk fetches
c.readerCache.MaybeCache(nextChunkViews, c.prefetchCount)
n, err = c.readerCache.ReadChunkAt(ctx, buffer, chunkView.FileId, chunkView.CipherKey, chunkView.IsGzipped, int64(offset), int(chunkView.ChunkSize), shouldCache)
if c.lastChunkFid != chunkView.FileId {
if chunkView.OffsetInChunk == 0 { // start of a new chunk
if c.lastChunkFid != "" {
c.readerCache.UnCache(c.lastChunkFid)
}
if nextChunkViews != nil && c.prefetchCount > 0 {
// Prefetch multiple chunks ahead for better sequential read throughput
// This keeps the network pipeline full with parallel chunk fetches
c.readerCache.MaybeCache(nextChunkViews, c.prefetchCount)
}
}
}
c.lastChunkFid = chunkView.FileId
return
}
-283
View File
@@ -1,283 +0,0 @@
package filer
import (
"context"
"fmt"
"io"
"sync"
"sync/atomic"
"testing"
"github.com/seaweedfs/seaweedfs/weed/util/chunk_cache"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// Two streams (e.g. two S3 GETs) reading the same object through one shared
// ReaderCache, interleaved in small slices, must share each chunk download:
// one stream finishing a chunk, or moving on to the next one, must not drop a
// buffer the other stream is still reading.
func TestChunkReadAtConcurrentStreamsShareChunks(t *testing.T) {
const chunkSize = 64 << 10
const chunkCount = 3
const sliceSize = 16 << 10
var mu sync.Mutex
fetches := map[string]int{}
rc := NewReaderCache(64, (*chunk_cache.TieredChunkCache)(nil), func(context.Context, string) ([]string, error) {
return []string{"unused"}, nil
}, nil)
defer rc.destroy()
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, fileId string, _ util_http.RefreshUrlsFunc) (int, error) {
mu.Lock()
fetches[fileId]++
mu.Unlock()
for i := range buffer {
buffer[i] = fileId[len(fileId)-1]
}
return len(buffer), nil
}
newStream := func() *ChunkReadAt {
views := NewIntervalList[*ChunkView]()
for i := 0; i < chunkCount; i++ {
views.AppendInterval(&Interval[*ChunkView]{
StartOffset: int64(i * chunkSize),
StopOffset: int64((i + 1) * chunkSize),
Value: &ChunkView{
FileId: fmt.Sprintf("chunk%d", i),
ViewSize: chunkSize,
ViewOffset: int64(i * chunkSize),
ChunkSize: chunkSize,
},
})
}
return NewChunkReaderAtFromClient(context.Background(), rc, views, chunkSize*chunkCount, 0)
}
streams := []*ChunkReadAt{newStream(), newStream()}
// The streams alternate slice by slice, the second one lagging one slice
// behind, so the leader always finishes a chunk while the follower is
// still inside it.
offsets := make([]int64, len(streams))
offsets[1] = -sliceSize
for offsets[1] < chunkSize*chunkCount {
for i, stream := range streams {
if offsets[i] < 0 || offsets[i] >= chunkSize*chunkCount {
offsets[i] += sliceSize
continue
}
buf := make([]byte, sliceSize)
n, err := stream.ReadAt(buf, offsets[i])
if (err != nil && err != io.EOF) || n != sliceSize {
t.Fatalf("stream %d at %d: n=%d err=%v", i, offsets[i], n, err)
}
if want := byte('0' + offsets[i]/chunkSize); buf[0] != want || buf[n-1] != want {
t.Fatalf("stream %d at %d: got %q, want %q", i, offsets[i], buf[0], want)
}
offsets[i] += sliceSize
}
}
mu.Lock()
defer mu.Unlock()
for i := 0; i < chunkCount; i++ {
fileId := fmt.Sprintf("chunk%d", i)
if fetches[fileId] != 1 {
t.Errorf("%s fetched %d times for two concurrent streams, want 1", fileId, fetches[fileId])
}
}
}
func newStreamTestReaderCache(chunkCache chunk_cache.ChunkCache) *ReaderCache {
rc := NewReaderCache(64, chunkCache, func(context.Context, string) ([]string, error) {
return []string{"unused"}, nil
}, nil)
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, _ string, _ util_http.RefreshUrlsFunc) (int, error) {
return len(buffer), nil
}
return rc
}
func isRetained(rc *ReaderCache, fileId string) bool {
rc.Lock()
defer rc.Unlock()
_, found := rc.downloaders[fileId]
return found
}
// A stream moving on to a chunk served from the chunk cache must still
// release the chunk it was positioned in before.
func TestChunkStreamReleasesPreviousChunkOnCacheHit(t *testing.T) {
cache := newMockChunkCacheForReaderCache()
cache.SetChunk("chunk1", make([]byte, 4<<10))
rc := newStreamTestReaderCache(cache)
defer rc.destroy()
stream := &chunkStream{}
if _, err := rc.readChunkAt(context.Background(), stream, make([]byte, 1<<10), "chunk0", nil, false, 0, 4<<10, false); err != nil {
t.Fatal(err)
}
if n, err := rc.readChunkAt(context.Background(), stream, make([]byte, 1<<10), "chunk1", nil, false, 0, 4<<10, true); err != nil || n == 0 {
t.Fatalf("cache hit read: n=%d err=%v", n, err)
}
if isRetained(rc, "chunk0") {
t.Fatal("chunk0 still retained after the stream moved on to a cached chunk")
}
}
// A chunk the stream leaves while another read is in flight must be dropped
// once that read ends, even if it did not read the chunk to the end.
func TestChunkStreamDropsLeftChunkAfterInFlightRead(t *testing.T) {
rc := newStreamTestReaderCache(newMockChunkCacheForReaderCache())
defer rc.destroy()
stream := &chunkStream{}
if _, err := rc.readChunkAt(context.Background(), stream, make([]byte, 1<<10), "chunk0", nil, false, 0, 4<<10, false); err != nil {
t.Fatal(err)
}
// Another reader is in the middle of a partial read of chunk0.
rc.Lock()
other := rc.downloaders["chunk0"]
other.wg.Add(1)
atomic.AddInt32(&other.readers, 1)
rc.Unlock()
if _, err := rc.readChunkAt(context.Background(), stream, make([]byte, 1<<10), "chunk1", nil, false, 0, 4<<10, false); err != nil {
t.Fatal(err)
}
if !isRetained(rc, "chunk0") {
t.Fatal("chunk0 dropped while a read was still in flight")
}
// The in-flight read ends without reaching the end of the chunk.
other.wg.Done()
atomic.AddInt32(&other.readers, -1)
rc.removeConsumed(other)
if isRetained(rc, "chunk0") {
t.Fatal("chunk0 retained after the stream left it and the last read ended")
}
}
// Concurrent ReadAt calls on one ChunkReadAt (as mount does) share its stream
// pin; they must neither race on it nor leak or double-release pins.
func TestChunkStreamConcurrentReadsOnOneReader(t *testing.T) {
const chunkSize = 16 << 10
const chunkCount = 4
const sliceSize = 4 << 10
rc := newStreamTestReaderCache((*chunk_cache.TieredChunkCache)(nil))
defer rc.destroy()
views := NewIntervalList[*ChunkView]()
for i := 0; i < chunkCount; i++ {
views.AppendInterval(&Interval[*ChunkView]{
StartOffset: int64(i * chunkSize),
StopOffset: int64((i + 1) * chunkSize),
Value: &ChunkView{
FileId: fmt.Sprintf("chunk%d", i),
ViewSize: chunkSize,
ViewOffset: int64(i * chunkSize),
ChunkSize: chunkSize,
},
})
}
reader := NewChunkReaderAtFromClient(context.Background(), rc, views, chunkSize*chunkCount, 0)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for offset := int64(0); offset < chunkSize*chunkCount; offset += sliceSize {
if _, err := reader.ReadAt(make([]byte, sliceSize), offset); err != nil && err != io.EOF {
t.Error(err)
return
}
}
}()
}
wg.Wait()
// Finishing the last chunk releases the stream's final pin.
if _, err := reader.ReadAt(make([]byte, sliceSize), chunkSize*chunkCount-sliceSize); err != nil && err != io.EOF {
t.Fatal(err)
}
rc.Lock()
defer rc.Unlock()
for fileId, cacher := range rc.downloaders {
t.Errorf("%s retained after all reads finished: pins=%d readers=%d", fileId, atomic.LoadInt32(&cacher.pins), atomic.LoadInt32(&cacher.readers))
}
}
// A chunk a stream is positioned in must outlast downloader-limit eviction:
// otherwise a busy cache drops the buffer mid-stream and forces a refetch.
func TestChunkReadAtPinnedChunkSurvivesEviction(t *testing.T) {
const chunkSize = 64 << 10
var fetches int32
rc := NewReaderCache(2, newMockChunkCacheForReaderCache(), func(context.Context, string) ([]string, error) {
return []string{"unused"}, nil
}, nil)
defer rc.destroy()
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, fileId string, _ util_http.RefreshUrlsFunc) (int, error) {
if fileId == "chunk0" {
atomic.AddInt32(&fetches, 1)
}
return len(buffer), nil
}
stream := &chunkStream{}
buf := make([]byte, 16<<10)
// One slice in: the stream is positioned in chunk0 but has not left it.
if _, err := rc.readChunkAt(context.Background(), stream, buf, "chunk0", nil, false, 0, chunkSize, false); err != nil {
t.Fatal(err)
}
// Fill the downloader map past its limit with unpinned chunks.
for _, fileId := range []string{"chunk1", "chunk2", "chunk3"} {
if _, err := rc.ReadChunkAt(context.Background(), buf, fileId, nil, false, 0, chunkSize, true); err != nil {
t.Fatalf("read %s: %v", fileId, err)
}
}
// The stream's next slice must come from the still-pinned buffer.
if _, err := rc.readChunkAt(context.Background(), stream, buf, "chunk0", nil, false, 16<<10, chunkSize, false); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&fetches); got != 1 {
t.Errorf("chunk0 fetched %d times, want 1", got)
}
}
// When every downloader is pinned the limit still applies: the oldest pinned
// buffer is evicted so abandoned streams cannot grow memory past the limit.
func TestChunkReadAtPinnedEvictionFallsBackWhenAllPinned(t *testing.T) {
const chunkSize = 64 << 10
rc := NewReaderCache(2, newMockChunkCacheForReaderCache(), func(context.Context, string) ([]string, error) {
return []string{"unused"}, nil
}, nil)
defer rc.destroy()
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, _ string, _ util_http.RefreshUrlsFunc) (int, error) {
return len(buffer), nil
}
buf := make([]byte, 16<<10)
streamA := &chunkStream{}
streamB := &chunkStream{}
if _, err := rc.readChunkAt(context.Background(), streamA, buf, "chunk0", nil, false, 0, chunkSize, false); err != nil {
t.Fatal(err)
}
if _, err := rc.readChunkAt(context.Background(), streamB, buf, "chunk1", nil, false, 0, chunkSize, false); err != nil {
t.Fatal(err)
}
// Every downloader is now pinned; the next chunk must still get in.
if _, err := rc.readChunkAt(context.Background(), &chunkStream{}, buf, "chunk2", nil, false, 0, chunkSize, false); err != nil {
t.Fatal(err)
}
if isRetained(rc, "chunk0") {
t.Fatal("oldest pinned downloader was not evicted past the limit")
}
if !isRetained(rc, "chunk2") {
t.Fatal("new downloader missing after pinned fallback eviction")
}
}
+9 -128
View File
@@ -35,8 +35,6 @@ type SingleChunkCacher struct {
completedTimeNew int64
readers int32
consumed int32
pins int32 // streams currently positioned inside this chunk
left int32 // set once the last pinning stream moved on
sync.Mutex
parent *ReaderCache
chunkFileId string
@@ -114,19 +112,7 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
return
}
// chunkStream is one sequential reader's position in a shared ReaderCache.
// The chunk it is reading stays pinned until the stream reads it to the end or
// moves elsewhere, so another stream finishing or leaving the same chunk does
// not drop the buffer from under it.
type chunkStream struct {
cacher *SingleChunkCacher
}
func (rc *ReaderCache) ReadChunkAt(ctx context.Context, buffer []byte, fileId string, cipherKey []byte, isGzipped bool, offset int64, chunkSize int, shouldCache bool) (int, error) {
return rc.readChunkAt(ctx, nil, buffer, fileId, cipherKey, isGzipped, offset, chunkSize, shouldCache)
}
func (rc *ReaderCache) readChunkAt(ctx context.Context, stream *chunkStream, buffer []byte, fileId string, cipherKey []byte, isGzipped bool, offset int64, chunkSize int, shouldCache bool) (int, error) {
retry:
rc.Lock()
@@ -144,11 +130,8 @@ retry:
// start wg.Wait() on a zero counter while this read is about to register.
cacher.wg.Add(1)
atomic.AddInt32(&cacher.readers, 1)
previous := stream.pin(cacher)
rc.Unlock()
rc.unpin(previous)
n, err := cacher.readChunkAt(ctx, buffer, offset)
rc.releaseIfFinished(stream, cacher, offset, n, err, chunkSize)
if n > 0 || err != nil {
return n, err
}
@@ -161,34 +144,19 @@ retry:
if shouldCache || rc.lookupFileIdFn == nil {
n, err := rc.chunkCache.ReadChunkAt(buffer, fileId, uint64(offset))
if n > 0 {
// Served from the chunk cache: the stream has left its pinned chunk.
previous := stream.unpinLocked()
rc.Unlock()
rc.unpin(previous)
return n, err
}
}
// clean up old downloaders; prefer one no stream is positioned in, but
// fall back to a pinned one so abandoned pins cannot bypass the limit
// clean up old downloaders
if len(rc.downloaders) >= rc.limit {
oldestFid, oldestTime := "", time.Now().UnixNano()
pinnedFid, pinnedTime := "", int64(0)
for fid, downloader := range rc.downloaders {
completedTime := atomic.LoadInt64(&downloader.completedTimeNew)
if completedTime <= 0 {
continue
if completedTime > 0 && completedTime < oldestTime {
oldestFid, oldestTime = fid, completedTime
}
if atomic.LoadInt32(&downloader.pins) == 0 {
if completedTime < oldestTime {
oldestFid, oldestTime = fid, completedTime
}
} else if pinnedFid == "" || completedTime < pinnedTime {
pinnedFid, pinnedTime = fid, completedTime
}
}
if oldestFid == "" {
oldestFid = pinnedFid
}
if oldestFid != "" {
oldDownloader := rc.downloaders[oldestFid]
@@ -207,77 +175,9 @@ retry:
rc.downloaders[fileId] = cacher
cacher.wg.Add(1)
atomic.AddInt32(&cacher.readers, 1)
previous := stream.pin(cacher)
rc.Unlock()
rc.unpin(previous)
n, err := cacher.readChunkAt(ctx, buffer, offset)
rc.releaseIfFinished(stream, cacher, offset, n, err, chunkSize)
return n, err
}
// pin makes cacher the stream's current chunk and returns the chunk it was
// pinned to before, which the caller unpins once the ReaderCache lock is
// released. The stream is only touched under the ReaderCache lock, since
// concurrent ReadAt calls on one ChunkReadAt share it.
func (stream *chunkStream) pin(cacher *SingleChunkCacher) (previous *SingleChunkCacher) {
if stream == nil || stream.cacher == cacher {
return nil
}
previous = stream.cacher
stream.cacher = cacher
atomic.AddInt32(&cacher.pins, 1)
return previous
}
// unpinLocked detaches the stream from its chunk and returns that chunk for
// the caller to unpin once the ReaderCache lock is released.
func (stream *chunkStream) unpinLocked() (previous *SingleChunkCacher) {
if stream == nil {
return nil
}
previous = stream.cacher
stream.cacher = nil
return previous
}
// releaseIfFinished unpins the stream's chunk once the stream has read it to
// the end, since the stream will not come back to it.
func (rc *ReaderCache) releaseIfFinished(stream *chunkStream, cacher *SingleChunkCacher, offset int64, n int, err error, chunkSize int) {
if stream == nil || err != nil || offset+int64(n) < int64(chunkSize) {
return
}
var previous *SingleChunkCacher
rc.Lock()
if stream.cacher == cacher {
previous = stream.unpinLocked()
}
rc.Unlock()
rc.unpin(previous)
}
// releaseStream unpins whatever chunk the stream is positioned in.
func (rc *ReaderCache) releaseStream(stream *chunkStream) {
if stream == nil {
return
}
rc.Lock()
previous := stream.unpinLocked()
rc.Unlock()
rc.unpin(previous)
}
// unpin drops one stream's pin. Once no stream is positioned in the chunk it
// is dropped like UnCache would, as soon as no read is in progress either:
// here if none is, otherwise by the last read's removeConsumed.
func (rc *ReaderCache) unpin(downloader *SingleChunkCacher) {
if downloader == nil {
return
}
if atomic.AddInt32(&downloader.pins, -1) == 0 {
atomic.StoreInt32(&downloader.left, 1)
}
rc.removeConsumed(downloader)
return cacher.readChunkAt(ctx, buffer, offset)
}
func (rc *ReaderCache) UnCache(fileId string) {
@@ -302,34 +202,15 @@ func (rc *ReaderCache) remove(downloader *SingleChunkCacher) {
}
}
// removeUnpinned drops a cacher only while no stream is positioned in it.
// Budget eviction picks its victim under the budget lock, so the pin check
// and the map removal must happen together under the ReaderCache lock.
func (rc *ReaderCache) removeUnpinned(downloader *SingleChunkCacher) (removed bool) {
rc.Lock()
removed = rc.downloaders[downloader.chunkFileId] == downloader &&
atomic.LoadInt32(&downloader.pins) == 0
if removed {
delete(rc.downloaders, downloader.chunkFileId)
}
rc.Unlock()
if removed {
downloader.destroy()
}
return
}
// removeConsumed drops a cacher once its buffer was fully read, or the
// streams positioned in it have left, and no readers remain attached or
// pinned. The checks run under the ReaderCache lock so a reader attaching
// at the same time either wins (the cacher stays and that reader's detach
// retries the removal) or misses the map and refetches.
// removeConsumed drops a cacher once its buffer was fully read and no
// readers remain attached. The checks run under the ReaderCache lock so a
// reader attaching at the same time either wins (the cacher stays and that
// reader's detach retries the removal) or misses the map and refetches.
func (rc *ReaderCache) removeConsumed(downloader *SingleChunkCacher) {
rc.Lock()
removed := rc.downloaders[downloader.chunkFileId] == downloader &&
atomic.LoadInt32(&downloader.readers) == 0 &&
atomic.LoadInt32(&downloader.pins) == 0 &&
(atomic.LoadInt32(&downloader.consumed) != 0 || atomic.LoadInt32(&downloader.left) != 0)
atomic.LoadInt32(&downloader.consumed) != 0
if removed {
delete(rc.downloaders, downloader.chunkFileId)
}
+3 -31
View File
@@ -4,7 +4,6 @@ import (
"container/list"
"fmt"
"sync"
"sync/atomic"
"github.com/seaweedfs/seaweedfs/weed/util/mem"
)
@@ -52,39 +51,12 @@ func (b *ReaderCacheBudget) reserve(s *SingleChunkCacher) error {
b.Unlock()
return nil
}
// Prefer evicting an idle chunk no stream is positioned in; fall back
// to the oldest pinned one so abandoned pins cannot block the budget.
var victim *SingleChunkCacher
var entry *list.Element
pinnedVictim := false
for e := b.idle.Front(); e != nil; e = e.Next() {
c := e.Value.(*SingleChunkCacher)
if atomic.LoadInt32(&c.pins) == 0 {
victim, entry = c, e
pinnedVictim = false
break
}
if victim == nil {
victim, entry = c, e
pinnedVictim = true
}
}
if entry != nil {
if entry := b.idle.Front(); entry != nil {
victim := entry.Value.(*SingleChunkCacher)
b.idle.Remove(entry)
delete(b.idleEntries, victim)
b.Unlock()
if pinnedVictim {
victim.parent.remove(victim)
} else if !victim.parent.removeUnpinned(victim) {
// The victim was pinned between selection and removal: keep it
// evictable so a pin abandoned in that gap cannot wedge the
// budget, then retry the selection.
b.Lock()
if _, ok := b.reservations[victim]; ok && b.idleEntries[victim] == nil {
b.idleEntries[victim] = b.idle.PushBack(victim)
}
b.Unlock()
}
victim.parent.remove(victim)
continue
}
changed := b.changed
+5 -27
View File
@@ -36,7 +36,6 @@ type FilerRemoteStorage struct {
// whenever /etc/remote changes
mu sync.RWMutex
rules ptrie.Trie[*remote_pb.RemoteStorageLocation]
mountDirs []util.FullPath
storageNameToConf map[string]*remote_pb.RemoteConf
// confValidator, when set, is applied to every RemoteConf as it is loaded
// from /etc/remote. A conf that fails is dropped from storageNameToConf so
@@ -88,13 +87,11 @@ func (rs *FilerRemoteStorage) LoadRemoteStorageConfigurationsAndMapping(filer *F
// build into fresh containers so an unmounted directory disappears instead
// of lingering in the trie, which has no way to drop a key
rules := ptrie.New[*remote_pb.RemoteStorageLocation]()
var mountDirs []util.FullPath
storageNameToConf := make(map[string]*remote_pb.RemoteConf)
for _, entry := range entries {
if entry.Name() == REMOTE_STORAGE_MOUNT_FILE {
mountDirs, err = loadRemoteStorageMountMapping(rules, entry.Content)
if err != nil {
if err := loadRemoteStorageMountMapping(rules, entry.Content); err != nil {
return err
}
continue
@@ -120,46 +117,27 @@ func (rs *FilerRemoteStorage) LoadRemoteStorageConfigurationsAndMapping(filer *F
}
rs.mu.Lock()
rs.rules, rs.mountDirs, rs.storageNameToConf = rules, mountDirs, storageNameToConf
rs.rules, rs.storageNameToConf = rules, storageNameToConf
rs.mu.Unlock()
return nil
}
func loadRemoteStorageMountMapping(rules ptrie.Trie[*remote_pb.RemoteStorageLocation], data []byte) (mountDirs []util.FullPath, err error) {
func loadRemoteStorageMountMapping(rules ptrie.Trie[*remote_pb.RemoteStorageLocation], data []byte) (err error) {
mappings := &remote_pb.RemoteStorageMapping{}
if err := proto.Unmarshal(data, mappings); err != nil {
return nil, fmt.Errorf("unmarshal %s/%s: %v", DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, err)
return fmt.Errorf("unmarshal %s/%s: %v", DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, err)
}
for dir, storageLocation := range mappings.Mappings {
putDirectoryToRemoteStorage(rules, util.FullPath(dir), storageLocation)
mountDirs = append(mountDirs, util.FullPath(dir))
}
return mountDirs, nil
return nil
}
func (rs *FilerRemoteStorage) mapDirectoryToRemoteStorage(dir util.FullPath, loc *remote_pb.RemoteStorageLocation) {
rs.mu.Lock()
defer rs.mu.Unlock()
putDirectoryToRemoteStorage(rs.rules, dir, loc)
found := false
for _, d := range rs.mountDirs {
if d == dir {
found = true
break
}
}
if !found {
rs.mountDirs = append(rs.mountDirs, dir)
}
}
// MountedDirectories returns the directories currently mapped to remote
// storage, for callers that must reason about every mount rather than one path.
func (rs *FilerRemoteStorage) MountedDirectories() []util.FullPath {
rs.mu.RLock()
defer rs.mu.RUnlock()
return append([]util.FullPath(nil), rs.mountDirs...)
}
func putDirectoryToRemoteStorage(rules ptrie.Trie[*remote_pb.RemoteStorageLocation], dir util.FullPath, loc *remote_pb.RemoteStorageLocation) {
@@ -116,14 +116,6 @@ func (c *GenericCachedRoleStore) DeleteRole(ctx context.Context, filerAddress st
return c.Delete(ctx, filerAddress, roleName)
}
// UpdateRole implements RoleStore interface. The update reads the filer, not
// the cache, and the cache is dropped afterwards whatever the outcome: a
// refused write may mean the cached role is stale.
func (c *GenericCachedRoleStore) UpdateRole(ctx context.Context, filerAddress string, roleName string, update RoleUpdate) error {
defer c.ClearCache()
return c.adapter.store.UpdateRole(ctx, filerAddress, roleName, update)
}
// genericCopyRoleDefinition creates a deep copy of a RoleDefinition for the generic cache
func genericCopyRoleDefinition(role *RoleDefinition) *RoleDefinition {
if role == nil {
@@ -131,13 +123,9 @@ func genericCopyRoleDefinition(role *RoleDefinition) *RoleDefinition {
}
result := &RoleDefinition{
RoleName: role.RoleName,
RoleArn: role.RoleArn,
Description: role.Description,
MaxSessionDuration: role.MaxSessionDuration,
Source: role.Source,
CreatedAt: role.CreatedAt,
RoleId: role.RoleId,
RoleName: role.RoleName,
RoleArn: role.RoleArn,
Description: role.Description,
}
// Deep copy trust policy if it exists
+71 -572
View File
@@ -2,10 +2,7 @@ package integration
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -30,30 +27,11 @@ const maxPoliciesForEvaluation = 1024
// IAMManager orchestrates all IAM components
type IAMManager struct {
stsService *sts.STSService
policyEngine *policy.PolicyEngine
roleStore RoleStore
userStore UserStore
oidcProviderStore OIDCProviderStore
// staticOIDCProviders are the OIDC providers of this server's IAM config
// file, by ARN. With an in-memory store they are also written to the
// store; a persistent store never holds them (see installOIDCProviderStore).
staticOIDCProviders map[string]*OIDCProviderRecord
// staticRoles are the roles of this server's IAM config file, by name, once
// LoadStaticRoles has run (see installRoleStore).
staticRoles map[string]*RoleDefinition
// oidcRetryMu guards the background refresh retry and which store is
// current: cancelOIDCLoad stops the retry, oidcRetryGen names the one
// running (0 when none) so at most one runs, and oidcRetryAgain records a
// refresh that failed while it ran, so the retry runs once more.
oidcRetryMu sync.Mutex
cancelOIDCLoad context.CancelFunc
oidcRetryGen uint64
oidcRetrySeq uint64
oidcRetryAgain bool
// oidcRefreshMu serializes refreshes from reading the store to handing
// STS the result, so an older snapshot cannot replace a newer one.
oidcRefreshMu sync.Mutex
stsService *sts.STSService
policyEngine *policy.PolicyEngine
roleStore RoleStore
userStore UserStore
oidcProviderStore OIDCProviderStore
oidcAuditSink OIDCProviderAuditSink
revocationStore SessionRevocationStore
filerAddressProvider func() string // Function to get current filer address
@@ -130,13 +108,12 @@ func (m *IAMManager) PurgeRevokedSessions(ctx context.Context) (int, error) {
}
// SetOIDCProviderStore configures the IAM-managed OIDC provider store. When
// nil, OIDC provider IAM actions return ServiceNotReady.
// nil, OIDC provider IAM actions return ServiceNotReady. The store is the
// source of truth for AssumeRoleWithWebIdentity provider resolution once
// Phase 2b lands; in Phase 2a it is read-only and populated from static
// configuration at boot.
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
var stsConfig *sts.STSConfig
if m.stsService != nil {
stsConfig = m.stsService.Config
}
m.installOIDCProviderStore(store, stsConfig)
m.oidcProviderStore = store
}
// GetOIDCProviderStore returns the configured store (may be nil).
@@ -144,33 +121,13 @@ func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
return m.oidcProviderStore
}
// GetRoleStore returns the configured role store.
func (m *IAMManager) GetRoleStore() RoleStore {
return m.roleStore
}
// SetRoleStore replaces the role store. An S3 server builds the list of
// directories it watches for peers' changes once, at startup, from the store
// installed then (RoleStoreDirectory): a filer-backed store installed later
// with a different basePath is not watched, so peers' changes to it reach this
// server's cached roles only when the cache expires.
func (m *IAMManager) SetRoleStore(store RoleStore) {
m.installRoleStore(context.Background(), store)
}
// GetOIDCProvider returns the record for the given ARN, or an error if the
// store is not configured or the record is missing.
func (m *IAMManager) GetOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
if m.oidcProviderStore == nil {
return nil, fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if errors.Is(err, ErrOIDCProviderNotFound) {
if static, ok := m.staticOIDCProviders[arn]; ok {
return copyOIDCProviderRecord(static), nil
}
}
return rec, err
return m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
}
// ListOIDCProviders enumerates all configured OIDC providers.
@@ -178,33 +135,7 @@ func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderReco
if m.oidcProviderStore == nil {
return nil, fmt.Errorf("OIDC provider store not configured")
}
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return nil, err
}
// A persistent store does not hold the config file's providers.
seen := make(map[string]bool, len(records))
for _, rec := range records {
seen[rec.ARN] = true
}
for arn, static := range m.staticOIDCProviders {
if !seen[arn] {
records = append(records, copyOIDCProviderRecord(static))
}
}
return records, nil
}
// mutableOIDCProvider loads a stored provider for a change. A provider only
// the IAM config file defines is refused with ErrOIDCProviderStatic.
func (m *IAMManager) mutableOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if errors.Is(err, ErrOIDCProviderNotFound) {
if _, ok := m.staticOIDCProviders[arn]; ok {
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderStatic, arn)
}
}
return rec, err
return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
}
// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses
@@ -219,9 +150,6 @@ func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRe
if err := validateOIDCProviderRecord(rec); err != nil {
return err
}
if _, static := m.staticOIDCProviders[rec.ARN]; static {
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
}
existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN)
if err == nil && existing != nil {
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
@@ -245,9 +173,6 @@ func (m *IAMManager) DeleteOIDCProvider(ctx context.Context, arn string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
if _, err := m.mutableOIDCProvider(ctx, arn); errors.Is(err, ErrOIDCProviderStatic) {
return err
}
if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil {
return err
}
@@ -265,7 +190,7 @@ func (m *IAMManager) AddClientIDToOIDCProvider(ctx context.Context, arn, clientI
if clientID == "" {
return fmt.Errorf("ClientID cannot be empty")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
@@ -293,7 +218,7 @@ func (m *IAMManager) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, cl
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
@@ -330,7 +255,7 @@ func (m *IAMManager) UpdateOIDCProviderThumbprints(ctx context.Context, arn stri
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
}
}
rec, err := m.mutableOIDCProvider(ctx, arn)
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
@@ -349,7 +274,7 @@ func (m *IAMManager) TagOIDCProvider(ctx context.Context, arn string, tags map[s
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
@@ -372,7 +297,7 @@ func (m *IAMManager) UntagOIDCProvider(ctx context.Context, arn string, keys []s
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
@@ -490,88 +415,6 @@ type RoleDefinition struct {
// set it must satisfy AWS bounds: 3600 ≤ MaxSessionDuration ≤ 43200.
// Honoured by AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithCredentials.
MaxSessionDuration int64 `json:"maxSessionDuration,omitempty"`
// Source records where the role came from. RoleSourceStaticConfig marks a
// role loaded from the IAM config file; empty means it was created at
// runtime. Only static-config roles are pruned when they leave the file.
Source string `json:"source,omitempty"`
// CreatedAt is when the role was created through the IAM API. Zero for
// roles loaded from the config file.
CreatedAt time.Time `json:"createdAt,omitempty"`
// RoleId uniquely identifies this role, as AWS's RoleId does. A role
// deleted and created again under the same name gets a new ID, and a
// session is honoured only while the role it was issued for still has
// the ID the session carries — so a session outlives neither the role's
// deletion nor a later role that reuses its name.
RoleId string `json:"roleId,omitempty"`
}
// NewRoleID returns a fresh, random role ID in AWS's AROA form.
func NewRoleID() string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
b := make([]byte, 17)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("read random role id: %v", err))
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "AROA" + string(b)
}
// StaticRoleID is the ID of a role defined in the IAM config file. Such a
// role is created again at every start, so its ID is derived rather than
// random, to keep sessions valid across restarts. It is derived from the name
// and the trust policy together: a role removed from the file and replaced by
// a different one under the same name trusts different principals, and must
// not inherit the old role's sessions. Restoring the same role restores its ID.
func StaticRoleID(role *RoleDefinition) string {
h := sha256.New()
h.Write([]byte("static-role:" + role.RoleName + "\x00"))
if role.TrustPolicy != nil {
trust, err := json.Marshal(role.TrustPolicy)
if err != nil {
// A trust policy that cannot be encoded cannot be matched on
// either; give the role an ID no session can carry.
return NewRoleID()
}
h.Write(trust)
}
return "AROA" + strings.ToUpper(hex.EncodeToString(h.Sum(nil)))[:17]
}
// RoleSourceStaticConfig is the Source of a role loaded from the IAM config
// file.
const RoleSourceStaticConfig = "static-config"
// checkSessionRoleBinding refuses a session issued for a role that has since
// been deleted, or replaced by a role reusing its name: the role's current ID
// must be the one the session carries. It runs for every session carrying a
// role ID, whatever policies the session embeds — the policies a session
// embeds are the ones its role had, and outlive the role otherwise. A session
// issued before role IDs were recorded carries none and is not bound. It
// returns the role it checked, nil for an unbound session.
func (m *IAMManager) checkSessionRoleBinding(ctx context.Context, sessionInfo *sts.SessionInfo) (*RoleDefinition, error) {
if sessionInfo == nil || sessionInfo.RoleId == "" {
return nil, nil
}
roleName := utils.ExtractRoleNameFromArn(sessionInfo.RoleArn)
if roleName == "" {
return nil, nil
}
role, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if errors.Is(err, ErrRoleNotFound) {
return nil, fmt.Errorf("session was issued for role %s, which no longer exists", roleName)
}
if err != nil {
return nil, fmt.Errorf("resolve role %s for session: %w", roleName, err)
}
if role.RoleId != sessionInfo.RoleId {
return nil, fmt.Errorf("session was issued for an earlier role named %s", roleName)
}
return role, nil
}
// ActionRequest represents a request to perform an action
@@ -714,174 +557,55 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
if err != nil {
return err
}
m.installOIDCProviderStore(store, config.STS)
return nil
}
// installOIDCProviderStore makes store the manager's OIDC provider store.
//
// The IAM config file's providers are reported by the IAM API alongside the
// stored ones. An in-memory store holds them as records, as it always has. A
// persistent store never does: it outlives this process and may be shared by
// S3 servers with different config files, so a record written from one file
// would outlive its removal from that file and be trusted by every server.
// Those providers stay in memory (staticOIDCProviders) and STS keeps serving
// them from its static configuration; the store holds only providers created
// through the IAM API, and those are loaded into STS here.
//
// A provider stored under the same ARN as a config-file provider takes
// precedence, in the IAM API as in STS, which already prefers IAM-managed
// providers so that an API call can shadow a bootstrap entry. Deleting the
// stored provider brings the config-file one back.
func (m *IAMManager) installOIDCProviderStore(store OIDCProviderStore, stsConfig *sts.STSConfig) {
// Cancel the old store's retry and switch stores in one step, so a failed
// refresh of the old store cannot start a retry after the cancel.
m.oidcRetryMu.Lock()
m.stopOIDCRetryLocked()
m.oidcProviderStore = store
m.oidcRetryMu.Unlock()
m.staticOIDCProviders = staticOIDCProviderRecords(stsConfig)
if _, inMemory := store.(*MemoryOIDCProviderStore); inMemory {
ctx := context.Background()
now := time.Now().UTC()
for _, rec := range m.staticOIDCProviders {
mirrored := copyOIDCProviderRecord(rec)
mirrored.CreatedAt, mirrored.UpdatedAt = now, now
if err := store.StoreProvider(ctx, m.getFilerAddress(), mirrored); err != nil {
glog.Warningf("mirror static OIDC provider %s into store: %v", rec.ARN, err)
}
}
// The store now holds them and the API may change them, as before;
// the overlay is only for stores that must not hold them.
m.staticOIDCProviders = nil
return
}
// The metadata subscription only reports changes made from now on, so
// providers already in the store would stay unknown until one changes; a
// failed load is retried (RefreshOIDCProvidersFromStore).
if err := m.RefreshOIDCProvidersFromStore(context.Background()); err != nil {
glog.Warningf("load OIDC providers from the store at startup: %v; retrying in the background", err)
}
}
// startOIDCRetry retries loading store in the background until it succeeds.
// A store that is no longer current gets no retry: nothing would cancel it,
// and its eventual success would replace the current store's providers. When
// a retry is already running, it is asked to run once more instead, because
// it may already have listed a snapshot older than this failure.
func (m *IAMManager) startOIDCRetry(store OIDCProviderStore) {
m.oidcRetryMu.Lock()
defer m.oidcRetryMu.Unlock()
if store != m.oidcProviderStore {
return
if config.STS == nil {
return nil
}
if m.oidcRetryGen != 0 {
m.oidcRetryAgain = true
return
}
ctx, cancel := context.WithCancel(context.Background())
m.oidcRetrySeq++
gen := m.oidcRetrySeq
m.cancelOIDCLoad, m.oidcRetryGen = cancel, gen
bounds := oidcHydrateRetry // read here, not in the goroutine: it outlives its caller
go func() {
defer cancel()
for {
m.retryOIDCProviderLoad(ctx, store, bounds)
m.oidcRetryMu.Lock()
if m.oidcRetryGen != gen {
m.oidcRetryMu.Unlock()
return // cancelled: another store was installed
}
if m.oidcRetryAgain && ctx.Err() == nil {
m.oidcRetryAgain = false
m.oidcRetryMu.Unlock()
continue
}
m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false
m.oidcRetryMu.Unlock()
return
}
}()
}
// stopOIDCRetryLocked cancels a running retry, as installing another store
// must. The caller holds oidcRetryMu.
func (m *IAMManager) stopOIDCRetryLocked() {
if m.cancelOIDCLoad != nil {
m.cancelOIDCLoad()
}
m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false
}
// currentOIDCProviderStore is the installed store, read under oidcRetryMu.
func (m *IAMManager) currentOIDCProviderStore() OIDCProviderStore {
m.oidcRetryMu.Lock()
defer m.oidcRetryMu.Unlock()
return m.oidcProviderStore
}
// staticOIDCProviderRecords describes the enabled OIDC providers of the IAM
// config file as provider records.
func staticOIDCProviderRecords(stsConfig *sts.STSConfig) map[string]*OIDCProviderRecord {
out := map[string]*OIDCProviderRecord{}
if stsConfig == nil {
return out
}
for _, pc := range stsConfig.Providers {
for _, pc := range config.STS.Providers {
if pc == nil || !pc.Enabled || pc.Type != sts.ProviderTypeOIDC {
continue
}
issuer, _ := pc.Config["issuer"].(string)
if issuer == "" {
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping", pc.Name)
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping mirror to store", pc.Name)
continue
}
arn, err := DeriveOIDCProviderARN(stsConfig.AccountId, issuer)
accountID := ""
if config.STS != nil {
accountID = config.STS.AccountId
}
arn, err := DeriveOIDCProviderARN(accountID, issuer)
if err != nil {
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
continue
}
out[arn] = &OIDCProviderRecord{
AccountID: stsConfig.AccountId,
clientIDs := extractClientIDs(pc.Config)
ctx := context.Background()
// Preserve CreatedAt across reboots when a persistent store already
// has this provider — IAM's GetOpenIDConnectProvider response
// shouldn't shift its CreateDate every time the server restarts.
now := time.Now().UTC()
createdAt := now
if existing, err := store.GetProviderByARN(ctx, m.getFilerAddress(), arn); err == nil && existing != nil && !existing.CreatedAt.IsZero() {
createdAt = existing.CreatedAt
}
rec := &OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: issuer,
ClientIDs: extractClientIDs(pc.Config),
ClientIDs: clientIDs,
Thumbprints: extractStringList(pc.Config, "thumbprints"),
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
PolicyClaim: extractString(pc.Config, "policyClaim"),
// No CreatedAt: a config-file provider has no creation time the
// server could report consistently across restarts.
CreatedAt: createdAt,
UpdatedAt: now,
}
}
return out
}
// oidcHydrateRetry bounds the backoff between startup load attempts.
var oidcHydrateRetry = struct{ initial, max time.Duration }{initial: time.Second, max: 30 * time.Second}
// retryOIDCProviderLoad retries loading store, the store it was started for,
// until it succeeds or ctx is cancelled because another store was installed.
// It calls refreshOIDCProvidersFrom, not RefreshOIDCProvidersFromStore, so it
// never schedules a retry of its own.
func (m *IAMManager) retryOIDCProviderLoad(ctx context.Context, store OIDCProviderStore, bounds struct{ initial, max time.Duration }) {
delay := bounds.initial
for {
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
err := m.refreshOIDCProvidersFrom(ctx, store)
if err == nil {
glog.V(0).Infof("loaded OIDC providers from the store after retrying")
return
}
glog.V(1).Infof("load OIDC providers from the store: %v; retrying in %v", err, delay)
if delay *= 2; delay > bounds.max {
delay = bounds.max
if err := store.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
glog.Warningf("mirror static OIDC provider %s into store: %v", pc.Name, err)
}
}
return nil
}
// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and
@@ -902,46 +626,14 @@ func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn
// the store is empty (clears the IAM-managed map). Records with empty URLs
// or invalid configuration are logged and skipped so a single bad entry
// does not stop the rest from refreshing.
//
// A refresh that fails keeps retrying in the background until the store
// answers. Every caller needs that: a metadata-subscription event reports each
// change ONCE, so a refresh that found the filer unreachable on it would leave
// a peer's new provider untrusted, or a deleted one trusted, until an unrelated
// later change; the refresh after a local IAM API mutation and the startup load
// have the same shape. At most one retry runs.
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
store := m.currentOIDCProviderStore()
err := m.refreshOIDCProvidersFrom(ctx, store)
if err != nil && store != nil {
m.startOIDCRetry(store)
}
return err
}
// refreshOIDCProvidersFrom is RefreshOIDCProvidersFromStore for a given store.
func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCProviderStore) error {
if store == nil || m.stsService == nil {
if m.oidcProviderStore == nil || m.stsService == nil {
return nil
}
// Refreshes run concurrently: after an IAM API change, on a peer's change
// and in the startup retry. Unserialized, a refresh that read the store
// before a DeleteOIDCProvider could finish after that call's own refresh
// and keep the deleted provider trusted.
m.oidcRefreshMu.Lock()
defer m.oidcRefreshMu.Unlock()
records, err := store.ListProviders(ctx, m.getFilerAddress())
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return fmt.Errorf("list OIDC providers: %w", err)
}
// A snapshot of a store that has since been replaced must not replace the
// current store's providers: a retry is cancelled when another store is
// installed, and a refresh may have listed the old store just before.
if err := ctx.Err(); err != nil {
return err
}
if store != m.currentOIDCProviderStore() {
return fmt.Errorf("list OIDC providers: the store was replaced during the refresh")
}
byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records))
for _, rec := range records {
if rec == nil || rec.URL == "" {
@@ -972,11 +664,9 @@ func buildOIDCProviderFromRecord(rec *OIDCProviderRecord) (*oidc.OIDCProvider, e
return nil, fmt.Errorf("record cannot be nil")
}
cfg := &oidc.OIDCConfig{
Issuer: rec.URL,
ClientIDs: append([]string(nil), rec.ClientIDs...),
Thumbprints: append([]string(nil), rec.Thumbprints...),
AllowedPrincipalTagKeys: append([]string(nil), rec.AllowedPrincipalTagKeys...),
PolicyClaim: rec.PolicyClaim,
Issuer: rec.URL,
ClientIDs: append([]string(nil), rec.ClientIDs...),
Thumbprints: append([]string(nil), rec.Thumbprints...),
}
provider := oidc.NewOIDCProvider(rec.ARN)
if err := provider.Initialize(cfg); err != nil {
@@ -1110,51 +800,14 @@ func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleNa
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
if err := PrepareRoleDefinition(roleName, roleDef); err != nil {
return err
}
if roleDef.RoleId == "" {
roleDef.RoleId = NewRoleID()
}
// Store role definition
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
}
// UpdateRole changes a role atomically in the role store (see
// RoleStore.UpdateRole). update receives the role's current definition, nil
// when it does not exist, and its result is validated like CreateRole's; a
// nil result deletes the role.
// The IAM API's role actions use it, so a change made on one S3 server is
// neither lost to a concurrent change on another nor written over a delete.
func (m *IAMManager) UpdateRole(ctx context.Context, roleName string, update RoleUpdate) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
return m.roleStore.UpdateRole(ctx, "", roleName, func(current *RoleDefinition) (*RoleDefinition, error) {
next, err := update(current)
if err != nil || next == nil {
return next, err
}
if err := PrepareRoleDefinition(roleName, next); err != nil {
return nil, err
}
return next, nil
})
}
// PrepareRoleDefinition applies CreateRole's defaults and validation to a role
// about to be stored or loaded.
func PrepareRoleDefinition(roleName string, roleDef *RoleDefinition) error {
if roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
if roleDef == nil {
return fmt.Errorf("role definition cannot be nil")
}
if roleDef.RoleName == "" {
roleDef.RoleName = roleName
}
// Set role ARN if not provided
if roleDef.RoleArn == "" {
@@ -1174,122 +827,12 @@ func PrepareRoleDefinition(roleName string, roleDef *RoleDefinition) error {
return fmt.Errorf("MaxSessionDuration must be between 3600 and 43200 seconds, got %d", roleDef.MaxSessionDuration)
}
}
return nil
}
// LoadStaticRoles installs the roles of the IAM config file.
//
// An in-memory store holds them as records, as it always has. A persistent
// store never does: it outlives this process and may be shared by S3 servers
// with different config files, so a record written from one file would
// outlive its removal from that file and be honoured by every server. Those
// roles are served from memory instead, ahead of the store, and cannot be
// changed or deleted through the store (ErrRoleStatic). A role stored under
// the same name takes precedence. They report no creation time.
func (m *IAMManager) LoadStaticRoles(ctx context.Context, roles []*RoleDefinition) {
defs := make(map[string]*RoleDefinition, len(roles))
for _, role := range roles {
if role == nil {
continue
}
role.Source = RoleSourceStaticConfig
if role.RoleId == "" {
role.RoleId = StaticRoleID(role)
}
if err := PrepareRoleDefinition(role.RoleName, role); err != nil {
glog.Warningf("Failed to load role %s: %v", role.RoleName, err)
continue
}
defs[role.RoleName] = role
}
m.staticRoles = defs
m.installRoleStore(ctx, m.roleStore)
}
// installRoleStore makes store the role store, with the config-file roles
// installed in it as LoadStaticRoles describes, so a store set after startup
// behaves like the one set at startup.
func (m *IAMManager) installRoleStore(ctx context.Context, store RoleStore) {
if overlay, ok := store.(*staticRoleOverlay); ok {
store = overlay.inner
}
if store == nil || m.staticRoles == nil {
m.roleStore = store
return
}
if _, inMemory := store.(*MemoryRoleStore); inMemory {
for name, role := range m.staticRoles {
if err := store.StoreRole(ctx, "", name, role); err != nil {
glog.Warningf("Failed to create role %s: %v", name, err)
}
}
m.roleStore = store
return
}
m.roleStore = &staticRoleOverlay{static: m.staticRoles, inner: store}
// Store role definition
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
}
// GetRole retrieves a role definition by name.
// ListRoles returns every stored role definition.
func (m *IAMManager) ListRoles(ctx context.Context) ([]*RoleDefinition, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
}
names, err := m.roleStore.ListRoles(ctx, m.getFilerAddress())
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
roles := make([]*RoleDefinition, 0, len(names))
for _, name := range names {
role, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), name)
if errors.Is(err, ErrRoleNotFound) {
continue // deleted between list and read
}
if err != nil {
return nil, fmt.Errorf("get role %s: %w", name, err)
}
roles = append(roles, role)
}
return roles, nil
}
// DeleteRole removes a role definition.
func (m *IAMManager) DeleteRole(ctx context.Context, roleName string) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
if roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
return m.roleStore.DeleteRole(ctx, m.getFilerAddress(), roleName)
}
// InvalidateRoleCache drops any cached role definitions, so a change written
// to the store by a peer is seen on the next lookup rather than after the
// cache TTL.
func (m *IAMManager) InvalidateRoleCache() {
if cached, ok := m.roleStore.(interface{ ClearCache() }); ok {
cached.ClearCache()
}
}
// RoleStoreDirectory is the filer directory the role store keeps roles in,
// its configured basePath; empty when the store is not filer-backed. S3
// servers watch it to drop cached roles when a peer changes one.
func (m *IAMManager) RoleStoreDirectory() string {
store := m.roleStore
if overlay, ok := store.(*staticRoleOverlay); ok {
store = overlay.inner
}
if cached, ok := store.(*GenericCachedRoleStore); ok {
store = cached.adapter.store
}
if filerStore, ok := store.(*FilerRoleStore); ok {
return filerStore.basePath
}
return ""
}
func (m *IAMManager) GetRole(ctx context.Context, roleName string) (*RoleDefinition, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
@@ -1399,8 +942,7 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts
// Apply role-level MaxSessionDuration cap. The STS service still applies
// the global MaxSessionLength and the source-token-expiry cap on top of
// this; per-role takes precedence whenever it is the tightest bound.
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds())
request.RoleId = roleDef.RoleId
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration)
// Use STS service to assume the role
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
@@ -1463,54 +1005,22 @@ func extractIssuerFromJWT(token string) (string, error) {
return iss, nil
}
// capDurationByRole returns the session duration clamped to the role's
// MaxSessionDuration. An omitted DurationSeconds resolves to the configured
// default first, so the role bound caps defaults and explicit values alike.
// A nil request is only materialized when something tightened the default
// and the explicit value still passes the service's own input validation —
// everything else is left nil so the service resolves the default and its
// MaxSessionLength cap itself.
func capDurationByRole(requested *int64, roleMax, defaultSec, serviceMaxSec int64) *int64 {
if requested != nil {
d := *requested
if roleMax > 0 && d > roleMax {
d = roleMax
}
return &d
// capDurationByRole returns the requested duration clamped to the role's
// MaxSessionDuration. A nil requested duration is left nil so the STS
// service's calculateSessionDuration applies the global default (typically
// 1 hour) — substituting the role's max here would silently mint a 12h
// session for any caller who omitted DurationSeconds, which AWS does not
// do. The role-max upper bound still applies in the downstream cap chain
// once the request has a concrete duration.
func capDurationByRole(requested *int64, roleMax int64) *int64 {
if roleMax <= 0 || requested == nil {
return requested
}
d := defaultSec
if roleMax > 0 && d > roleMax {
d = roleMax
if *requested > roleMax {
v := roleMax
return &v
}
if d > serviceMaxSec {
d = serviceMaxSec
}
if d < 900 && roleMax > 0 {
d = 900
}
if d >= defaultSec || d < 900 {
return nil
}
return &d
}
func (m *IAMManager) defaultTokenDurationSeconds() int64 {
if m.stsService == nil || m.stsService.Config == nil {
return sts.DefaultTokenDuration
}
return int64(m.stsService.Config.TokenDuration.Duration / time.Second)
}
// maxSessionLengthSeconds mirrors validateSessionDurationSeconds so a
// materialized default stays inside the bound the service will enforce.
func (m *IAMManager) maxSessionLengthSeconds() int64 {
maxSec := int64(sts.DefaultMaxSessionLength)
if m.stsService != nil && m.stsService.Config != nil && m.stsService.Config.MaxSessionLength.Duration > 0 {
if configured := int64(m.stsService.Config.MaxSessionLength.Duration / time.Second); configured >= 900 {
maxSec = configured
}
}
return maxSec
return requested
}
// AssumeRoleWithCredentials assumes a role using credentials (LDAP)
@@ -1534,10 +1044,9 @@ func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts
}
// Apply role-level MaxSessionDuration cap.
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds())
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration)
// Use STS service to assume the role
request.RoleId = roleDef.RoleId
return m.stsService.AssumeRoleWithCredentials(ctx, request)
}
@@ -1551,10 +1060,6 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
// We always try to validate with the internal STS service first if it's a SeaweedFS token.
// This ensures that session policies embedded in the token are correctly extracted and enforced.
var sessionInfo *sts.SessionInfo
// boundRole is the role a session carrying a role ID was checked against;
// its policies are the ones evaluated, so the check and the evaluation
// see one definition even if the role is replaced in between.
var boundRole *RoleDefinition
if request.SessionToken != "" {
// Parse unverified to check issuer
parsed, _, err := new(jwt.Parser).ParseUnverified(request.SessionToken, jwt.MapClaims{})
@@ -1587,9 +1092,6 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
return false, fmt.Errorf("session has been revoked")
}
}
if boundRole, err = m.checkSessionRoleBinding(ctx, sessionInfo); err != nil {
return false, err
}
}
}
@@ -1686,12 +1188,9 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
policies = user.GetPolicyNames()
} else {
// Get role definition
roleDef := boundRole
if roleDef == nil || roleDef.RoleName != roleName {
roleDef, err = m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return false, fmt.Errorf("role not found: %s", roleName)
}
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return false, fmt.Errorf("role not found: %s", roleName)
}
hasManagedSubject = true
+5 -19
View File
@@ -2,30 +2,16 @@ package integration
import (
"context"
"errors"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
)
// ErrTrustPolicyDenied is wrapped when a role's trust policy does not admit
// the principal.
var ErrTrustPolicyDenied = errors.New("trust policy denies access to principal")
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
func (m *IAMManager) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
_, err := m.ResolveRoleForPrincipal(ctx, roleArn, principalArn)
return err
}
// ResolveRoleForPrincipal returns the role roleArn names if its trust policy
// admits principalArn. Issuing a session from the definition returned binds
// the session to the role whose trust was evaluated, not to a role that
// replaced it under the same name in between.
func (m *IAMManager) ResolveRoleForPrincipal(ctx context.Context, roleArn, principalArn string) (*RoleDefinition, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
return fmt.Errorf("IAM manager not initialized")
}
// Extract role name from ARN
@@ -34,11 +20,11 @@ func (m *IAMManager) ResolveRoleForPrincipal(ctx context.Context, roleArn, princ
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return nil, fmt.Errorf("failed to get role %s: %w", roleName, err)
return fmt.Errorf("failed to get role %s: %w", roleName, err)
}
if roleDef.TrustPolicy == nil {
return nil, fmt.Errorf("%w: role has no trust policy", ErrTrustPolicyDenied)
return fmt.Errorf("role has no trust policy")
}
// Create evaluation context with RequestContext populated so that
@@ -57,8 +43,8 @@ func (m *IAMManager) ResolveRoleForPrincipal(ctx context.Context, roleArn, princ
// Evaluate the trust policy
if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) {
return nil, fmt.Errorf("%w: %s", ErrTrustPolicyDenied, principalArn)
return fmt.Errorf("trust policy denies access to principal: %s", principalArn)
}
return roleDef, nil
return nil
}
@@ -1,561 +0,0 @@
package integration
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// persistentTestStore stands in for a store that outlives the process and may
// be shared (the filer store): anything but *MemoryOIDCProviderStore is
// treated as persistent.
type persistentTestStore struct{ *MemoryOIDCProviderStore }
const (
persistTestStaticIssuer = "https://static.example"
persistTestAPIIssuer = "https://api.example"
)
// persistTestConfig is an IAM config whose file defines the given OIDC
// issuers.
func persistTestConfig(issuers ...string) *IAMConfig {
var providers []*sts.ProviderConfig
for i, issuer := range issuers {
providers = append(providers, &sts.ProviderConfig{
Name: "static-" + string(rune('a'+i)),
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{"issuer": issuer, "clientId": "aud"},
})
}
return &IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
Providers: providers,
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &RoleStoreConfig{StoreType: "memory"},
}
}
// startServer initializes a manager as an S3 server would at boot, with the
// given config file providers and OIDC provider store.
func startServer(t *testing.T, store OIDCProviderStore, issuers ...string) *IAMManager {
t.Helper()
cfg := persistTestConfig(issuers...)
mgr := NewIAMManager()
require.NoError(t, mgr.Initialize(cfg, func() string { return "localhost:8888" }))
mgr.installOIDCProviderStore(store, cfg.STS)
return mgr
}
func arnOf(t *testing.T, issuer string) string {
t.Helper()
arn, err := DeriveOIDCProviderARN("", issuer)
require.NoError(t, err)
return arn
}
func listedARNs(t *testing.T, mgr *IAMManager) map[string]bool {
t.Helper()
recs, err := mgr.ListOIDCProviders(context.Background())
require.NoError(t, err)
out := map[string]bool{}
for _, r := range recs {
out[r.ARN] = true
}
return out
}
func storedARNs(t *testing.T, store OIDCProviderStore) map[string]bool {
t.Helper()
recs, err := store.ListProviders(context.Background(), "")
require.NoError(t, err)
out := map[string]bool{}
for _, r := range recs {
out[r.ARN] = true
}
return out
}
func createAPIProvider(t *testing.T, mgr *IAMManager, issuer string) {
t.Helper()
require.NoError(t, mgr.CreateOIDCProvider(context.Background(), &OIDCProviderRecord{
ARN: arnOf(t, issuer), URL: issuer, ClientIDs: []string{"aud"},
}))
}
// stsKnowsIssuer reports whether STS resolves a provider for the issuer. The
// token is not validly signed; the only question is which error comes back.
func stsKnowsIssuer(t *testing.T, mgr *IAMManager, issuer string) bool {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": issuer, "sub": "probe", "aud": "aud", "exp": time.Now().Add(time.Hour).Unix(),
}).SignedString([]byte("not-the-providers-key"))
require.NoError(t, err)
_, _, err = mgr.GetSTSService().ValidateWebIdentityToken(context.Background(), tok)
require.Error(t, err, "an unsigned-by-provider token for %s was accepted", issuer)
return !strings.Contains(err.Error(), "no identity provider registered")
}
// A persistent store never receives the config file's providers: it outlives
// the file and may be shared with servers whose files differ.
func TestPersistentStoreNeverHoldsConfigFileProviders(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
mgr := startServer(t, store, persistTestStaticIssuer)
assert.Empty(t, storedARNs(t, store), "a config-file provider was written to the persistent store")
assert.True(t, listedARNs(t, mgr)[arnOf(t, persistTestStaticIssuer)], "the IAM API no longer lists the config-file provider")
rec, err := mgr.GetOIDCProvider(context.Background(), arnOf(t, persistTestStaticIssuer))
require.NoError(t, err)
assert.Equal(t, persistTestStaticIssuer, rec.URL)
assert.True(t, stsKnowsIssuer(t, mgr, persistTestStaticIssuer), "STS stopped trusting the config-file provider")
}
// Servers sharing a store, with different config files, must not remove or
// replace each other's providers — including a zero-config server.
func TestServersSharingAStoreKeepEachOthersProviders(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
configured := startServer(t, store, persistTestStaticIssuer)
createAPIProvider(t, configured, persistTestAPIIssuer)
zeroConfig := startServer(t, store)
assert.True(t, storedARNs(t, store)[arnOf(t, persistTestAPIIssuer)], "a peer's start removed an API-created provider")
assert.True(t, listedARNs(t, configured)[arnOf(t, persistTestStaticIssuer)], "the configured server lost its config-file provider")
assert.False(t, listedARNs(t, zeroConfig)[arnOf(t, persistTestStaticIssuer)], "a server lists a provider only its peer's config file defines")
assert.True(t, stsKnowsIssuer(t, zeroConfig, persistTestAPIIssuer), "the peer does not trust the shared API-created provider")
assert.False(t, stsKnowsIssuer(t, zeroConfig, persistTestStaticIssuer), "the peer trusts a provider only another server's config file defines")
}
// Removing a provider from the config file revokes it at the next start.
func TestRemovingAProviderFromTheConfigFileRevokesIt(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
startServer(t, store, persistTestStaticIssuer)
restarted := startServer(t, store) // the file no longer lists it
assert.False(t, listedARNs(t, restarted)[arnOf(t, persistTestStaticIssuer)])
assert.False(t, stsKnowsIssuer(t, restarted, persistTestStaticIssuer), "a provider removed from the config file is still trusted")
}
// The API cannot change a provider the config file defines, nor create one
// with its ARN; the file is where it changes.
func TestConfigFileProvidersCannotBeChangedThroughTheAPI(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
mgr := startServer(t, store, persistTestStaticIssuer)
ctx := context.Background()
arn := arnOf(t, persistTestStaticIssuer)
for name, err := range map[string]error{
"add client ID": mgr.AddClientIDToOIDCProvider(ctx, arn, "other"),
"remove client ID": mgr.RemoveClientIDFromOIDCProvider(ctx, arn, "aud"),
"update thumbprint": mgr.UpdateOIDCProviderThumbprints(ctx, arn, []string{"9e99a48a9960b14926bb7f3b02e22da2b0ab7280"}),
"tag": mgr.TagOIDCProvider(ctx, arn, map[string]string{"k": "v"}),
"untag": mgr.UntagOIDCProvider(ctx, arn, []string{"k"}),
"delete": mgr.DeleteOIDCProvider(ctx, arn),
} {
assert.ErrorIs(t, err, ErrOIDCProviderStatic, name)
}
err := mgr.CreateOIDCProvider(ctx, &OIDCProviderRecord{ARN: arn, URL: persistTestStaticIssuer, ClientIDs: []string{"aud"}})
assert.ErrorIs(t, err, ErrOIDCProviderAlreadyExists)
assert.Empty(t, storedARNs(t, store), "a refused change still wrote to the store")
}
// Providers created through the API on an earlier boot, or on a peer, are
// trusted at startup rather than after the next change.
func TestStartupLoadsStoredProvidersIntoSTS(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
createAPIProvider(t, startServer(t, store), persistTestAPIIssuer)
restarted := startServer(t, store)
assert.True(t, stsKnowsIssuer(t, restarted, persistTestAPIIssuer))
}
// An in-memory store keeps its behaviour: the config file's providers are
// records in it, as before.
func TestInMemoryStoreStillHoldsConfigFileProviders(t *testing.T) {
store := NewMemoryOIDCProviderStore()
startServer(t, store, persistTestStaticIssuer)
assert.True(t, storedARNs(t, store)[arnOf(t, persistTestStaticIssuer)])
}
// unreachableThenReadyStore fails its first reads, as a filer that is not up
// yet when the S3 server starts.
type unreachableThenReadyStore struct {
*MemoryOIDCProviderStore
mu sync.Mutex
failsLeft int
}
func (s *unreachableThenReadyStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
s.mu.Lock()
if s.failsLeft > 0 {
s.failsLeft--
s.mu.Unlock()
return nil, errors.New("filer unavailable")
}
s.mu.Unlock()
return s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
}
// A store that cannot be read at startup is retried: the metadata
// subscription only reports later changes, so without a retry the providers
// already stored would stay unknown to STS.
func TestStartupLoadRetriesUntilTheStoreIsReadable(t *testing.T) {
saved := oidcHydrateRetry
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 5*time.Millisecond
t.Cleanup(func() { oidcHydrateRetry = saved })
seeded := NewMemoryOIDCProviderStore()
require.NoError(t, seeded.StoreProvider(context.Background(), "", &OIDCProviderRecord{
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
}))
mgr := startServer(t, &unreachableThenReadyStore{MemoryOIDCProviderStore: seeded, failsLeft: 3})
deadline := time.Now().Add(2 * time.Second)
for !stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
if time.Now().After(deadline) {
t.Fatal("STS never learned the stored provider after the store became readable")
}
time.Sleep(5 * time.Millisecond)
}
}
// A provider stored under a config-file provider's ARN takes precedence, as it
// does in STS; the API then changes the stored one, and deleting it brings the
// config-file provider back.
func TestStoredProviderTakesPrecedenceOverTheConfigFileOne(t *testing.T) {
ctx := context.Background()
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
configured := startServer(t, store, persistTestStaticIssuer)
peer := startServer(t, store)
arn := arnOf(t, persistTestStaticIssuer)
require.NoError(t, peer.CreateOIDCProvider(ctx, &OIDCProviderRecord{ARN: arn, URL: persistTestStaticIssuer, ClientIDs: []string{"stored"}}))
rec, err := configured.GetOIDCProvider(ctx, arn)
require.NoError(t, err)
assert.Equal(t, []string{"stored"}, rec.ClientIDs, "the config-file provider hides the stored one")
assert.NoError(t, configured.AddClientIDToOIDCProvider(ctx, arn, "more"), "the stored provider cannot be changed")
require.NoError(t, configured.DeleteOIDCProvider(ctx, arn))
rec, err = configured.GetOIDCProvider(ctx, arn)
require.NoError(t, err)
assert.Equal(t, []string{"aud"}, rec.ClientIDs, "deleting the stored provider did not bring the config-file one back")
}
// A store installed through SetOIDCProviderStore behaves like one installed
// at startup: stored providers load into STS and config-file providers stay
// visible to the IAM API.
func TestSetOIDCProviderStoreInstallsLikeStartup(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
require.NoError(t, store.StoreProvider(context.Background(), "", &OIDCProviderRecord{
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
}))
cfg := persistTestConfig(persistTestStaticIssuer)
mgr := NewIAMManager()
require.NoError(t, mgr.Initialize(cfg, func() string { return "localhost:8888" }))
mgr.SetOIDCProviderStore(store)
assert.True(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "a stored provider was not trusted after install")
assert.True(t, listedARNs(t, mgr)[arnOf(t, persistTestStaticIssuer)], "the IAM API no longer lists the config-file provider")
assert.ErrorIs(t, mgr.DeleteOIDCProvider(context.Background(), arnOf(t, persistTestStaticIssuer)), ErrOIDCProviderStatic)
}
// A config-file provider reports no creation time: a time taken at startup
// would change with every restart.
func TestConfigFileProvidersReportNoCreationTime(t *testing.T) {
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
mgr := startServer(t, store, persistTestStaticIssuer)
rec, err := mgr.GetOIDCProvider(context.Background(), arnOf(t, persistTestStaticIssuer))
require.NoError(t, err)
assert.True(t, rec.CreatedAt.IsZero())
}
// countingUnreadableStore never becomes readable and counts the attempts.
type countingUnreadableStore struct {
*MemoryOIDCProviderStore
mu sync.Mutex
reads int
}
func (s *countingUnreadableStore) ListProviders(context.Context, string) ([]*OIDCProviderRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.reads++
return nil, errors.New("filer unavailable")
}
func (s *countingUnreadableStore) readCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.reads
}
// Installing another store stops the previous one's startup retry.
func TestInstallingAnotherStoreStopsThePreviousRetry(t *testing.T) {
saved := oidcHydrateRetry
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, time.Millisecond
t.Cleanup(func() { oidcHydrateRetry = saved })
first := &countingUnreadableStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
mgr := startServer(t, first)
deadline := time.Now().Add(2 * time.Second)
for first.readCount() <= 1 {
if time.Now().After(deadline) {
t.Fatal("precondition: the first store is never retried")
}
time.Sleep(time.Millisecond)
}
mgr.installOIDCProviderStore(&persistentTestStore{NewMemoryOIDCProviderStore()}, persistTestConfig().STS)
time.Sleep(10 * time.Millisecond) // let an in-flight attempt finish
settled := first.readCount()
time.Sleep(30 * time.Millisecond)
assert.Equal(t, settled, first.readCount(), "the superseded store is still being retried")
}
// blockingListStore blocks the first ListProviders call after arm, having
// already read its snapshot, until release is closed.
type blockingListStore struct {
*MemoryOIDCProviderStore
mu sync.Mutex
armed bool
entered chan struct{}
release chan struct{}
}
func (s *blockingListStore) arm() {
s.mu.Lock()
defer s.mu.Unlock()
s.armed, s.entered, s.release = true, make(chan struct{}), make(chan struct{})
}
func (s *blockingListStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
records, err := s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
s.mu.Lock()
block := s.armed
s.armed = false
s.mu.Unlock()
if block {
close(s.entered)
<-s.release
}
return records, err
}
// A refresh that read the store before a provider was deleted cannot leave the
// deleted provider trusted by finishing after the deletion's own refresh.
func TestAnOlderRefreshCannotRestoreADeletedProvider(t *testing.T) {
store := &blockingListStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
mgr := startServer(t, store)
createAPIProvider(t, mgr, persistTestAPIIssuer)
require.True(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "precondition: the provider is trusted")
store.arm()
stale := make(chan struct{})
go func() {
defer close(stale)
_ = mgr.RefreshOIDCProvidersFromStore(context.Background())
}()
<-store.entered // the stale refresh holds a snapshot with the provider
deleted := make(chan error, 1)
go func() { deleted <- mgr.DeleteOIDCProvider(context.Background(), arnOf(t, persistTestAPIIssuer)) }()
// Serialized, the deletion's refresh waits for the stale one; otherwise
// let it finish first, which is the ordering that went wrong.
var deleteErr error
select {
case deleteErr = <-deleted:
deleted = nil
case <-time.After(200 * time.Millisecond):
}
close(store.release)
<-stale
if deleted != nil {
deleteErr = <-deleted
}
require.NoError(t, deleteErr)
assert.False(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "a refresh older than the deletion left the deleted provider trusted")
}
// A refresh that fails is retried until the store answers. A change event reports each mutation once, so
// a subscription refresh that found the filer unreachable — mid-restart, say — left the provider set stale
// until some unrelated later change; a peer's new provider stayed untrusted and a deleted one trusted.
func TestAFailedRefreshIsRetriedUntilTheStoreAnswers(t *testing.T) {
saved := oidcHydrateRetry
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 5*time.Millisecond
t.Cleanup(func() { oidcHydrateRetry = saved })
store := &unreachableThenReadyStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
mgr := startServer(t, store)
require.NoError(t, store.StoreProvider(context.Background(), "", &OIDCProviderRecord{
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
}))
store.mu.Lock()
store.failsLeft = 3
store.mu.Unlock()
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()), "precondition: the refresh fails")
deadline := time.Now().Add(2 * time.Second)
for !stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
if time.Now().After(deadline) {
t.Fatal("a failed refresh was never retried: the stored provider stays untrusted until an unrelated change")
}
time.Sleep(5 * time.Millisecond)
}
}
// Failures during an outage start ONE retry, not one per event: a filer that is down for a while produces a
// change event per mutation, and each would otherwise add a goroutine polling the same store. Once the store
// answers and the retry ends, a later failure starts a new one.
func TestFailedRefreshesShareOneRetry(t *testing.T) {
saved := oidcHydrateRetry
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, time.Millisecond
t.Cleanup(func() { oidcHydrateRetry = saved })
store := &unreachableThenReadyStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
mgr := startServer(t, store)
store.mu.Lock()
store.failsLeft = 1 << 30
store.mu.Unlock()
for range 20 {
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()))
}
mgr.oidcRetryMu.Lock()
started := mgr.oidcRetrySeq
mgr.oidcRetryMu.Unlock()
assert.Equal(t, uint64(1), started, "twenty failed refreshes started more than one retry")
store.mu.Lock()
store.failsLeft = 0
store.mu.Unlock()
deadline := time.Now().Add(2 * time.Second)
for {
mgr.oidcRetryMu.Lock()
running := mgr.oidcRetryGen != 0
mgr.oidcRetryMu.Unlock()
if !running {
break
}
if time.Now().After(deadline) {
t.Fatal("the retry never ended after the store answered")
}
time.Sleep(time.Millisecond)
}
store.mu.Lock()
store.failsLeft = 1
store.mu.Unlock()
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()))
mgr.oidcRetryMu.Lock()
assert.Equal(t, uint64(2), mgr.oidcRetrySeq, "a failure after the retry ended starts a new one")
mgr.oidcRetryMu.Unlock()
}
// flakyCountingStore fails while fail is set and counts the lists that succeed.
type flakyCountingStore struct {
*MemoryOIDCProviderStore
mu sync.Mutex
fail bool
ok int
}
func (s *flakyCountingStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
s.mu.Lock()
if s.fail {
s.mu.Unlock()
return nil, errors.New("filer unavailable")
}
s.ok++
s.mu.Unlock()
return s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
}
func (s *flakyCountingStore) set(fail bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.fail = fail
}
func (s *flakyCountingStore) successes() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.ok
}
// A refresh of store A that fails as store B is installed must not start a retry for A: nothing would cancel
// it, and when A answered it would replace B's providers (a removed one trusted again, B's own untrusted).
func TestAFailedRefreshOfASupersededStoreStartsNoRetry(t *testing.T) {
a := &persistentTestStore{NewMemoryOIDCProviderStore()}
mgr := startServer(t, a)
mgr.SetOIDCProviderStore(&persistentTestStore{NewMemoryOIDCProviderStore()})
mgr.oidcRetryMu.Lock()
before := mgr.oidcRetrySeq
mgr.oidcRetryMu.Unlock()
mgr.startOIDCRetry(a) // the late call of A's failed refresh, after B's install
mgr.oidcRetryMu.Lock()
defer mgr.oidcRetryMu.Unlock()
assert.Equal(t, before, mgr.oidcRetrySeq, "a retry was started for the store that was replaced")
assert.Zero(t, mgr.oidcRetryGen)
}
// A refresh that listed store A before B was installed must not hand STS A's providers afterwards.
func TestASupersededSnapshotIsNotApplied(t *testing.T) {
a := &persistentTestStore{NewMemoryOIDCProviderStore()}
require.NoError(t, a.StoreProvider(context.Background(), "", &OIDCProviderRecord{
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
}))
b := &persistentTestStore{NewMemoryOIDCProviderStore()}
require.NoError(t, b.StoreProvider(context.Background(), "", &OIDCProviderRecord{
ARN: arnOf(t, persistTestStaticIssuer), URL: persistTestStaticIssuer, ClientIDs: []string{"aud"},
}))
mgr := startServer(t, a)
mgr.SetOIDCProviderStore(b)
assert.Error(t, mgr.refreshOIDCProvidersFrom(context.Background(), a), "a snapshot of the replaced store was applied")
assert.False(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "the replaced store's provider is trusted")
assert.True(t, stsKnowsIssuer(t, mgr, persistTestStaticIssuer), "the current store's provider is not")
}
// A refresh that fails while a retry runs is not dropped: the retry may already have listed an older snapshot,
// so after its success it runs once more and picks up whatever the failed refresh would have seen.
func TestAFailureDuringARetryIsNotDropped(t *testing.T) {
saved := oidcHydrateRetry
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 2*time.Millisecond
t.Cleanup(func() { oidcHydrateRetry = saved })
store := &flakyCountingStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore(), fail: true}
mgr := startServer(t, store) // the startup load fails: a retry is running
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()), "a later refresh fails while it runs")
store.set(false)
deadline := time.Now().Add(2 * time.Second)
for {
mgr.oidcRetryMu.Lock()
running := mgr.oidcRetryGen != 0
mgr.oidcRetryMu.Unlock()
if !running {
break
}
if time.Now().After(deadline) {
t.Fatal("the retry never ended")
}
time.Sleep(time.Millisecond)
}
assert.GreaterOrEqual(t, store.successes(), 2, "the retry ended on its first success, dropping the refresh that failed while it ran")
}
+3 -11
View File
@@ -27,9 +27,6 @@ import (
var (
ErrOIDCProviderNotFound = errors.New("OIDC provider not found")
ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists")
// ErrOIDCProviderStatic refuses a change to a provider defined in the
// server's IAM config file: change it there instead.
ErrOIDCProviderStatic = errors.New("OIDC provider is defined in the IAM config file")
)
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
@@ -270,20 +267,15 @@ func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddr
var data []byte
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
})
// Only a confirmed absence is ErrOIDCProviderNotFound: callers create
// on it, so an unreachable filer must not read as "no such provider".
if errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
}
if err != nil {
return fmt.Errorf("lookup OIDC provider %s: %w", arn, err)
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
}
if resp.Entry == nil {
return fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
return fmt.Errorf("OIDC provider not found: %s", arn)
}
data = resp.Entry.Content
return nil
@@ -1,165 +0,0 @@
package integration
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A session is bound to the role it was issued for, not to the role's name:
// deleting the role ends it, and a role created later under the same name —
// by whoever may create roles — must not inherit it.
func TestSessionIsBoundToTheRoleNotItsName(t *testing.T) {
ctx := context.Background()
m := setupIntegratedIAMSystem(t)
assume := func() (token, principal string) {
resp, err := m.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
WebIdentityToken: createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key"),
RoleSessionName: "binding-test",
})
require.NoError(t, err)
return resp.Credentials.SessionToken, resp.AssumedRoleUser.Arn
}
allowed := func(token, principal string) bool {
ok, _ := m.IsActionAllowed(ctx, &ActionRequest{
Principal: principal,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/file.txt",
SessionToken: token,
})
return ok
}
original, err := m.GetRole(ctx, "S3ReadOnlyRole")
require.NoError(t, err)
require.NotEmpty(t, original.RoleId, "a created role has an ID")
token, principal := assume()
require.True(t, allowed(token, principal), "precondition: the session works")
require.NoError(t, m.DeleteRole(ctx, "S3ReadOnlyRole"))
assert.False(t, allowed(token, principal), "the session outlived its role's deletion")
recreated := *original
recreated.RoleId = ""
require.NoError(t, m.CreateRole(ctx, "", "S3ReadOnlyRole", &recreated))
assert.NotEqual(t, original.RoleId, recreated.RoleId, "a role created again under the same name got the same ID")
assert.False(t, allowed(token, principal), "a session of the deleted role works again under a new role of the same name")
fresh, freshPrincipal := assume()
assert.True(t, allowed(fresh, freshPrincipal), "a session issued for the new role is refused")
}
// A session carries its role's attached policies, and S3 evaluates those
// rather than looking the role up; the binding must hold on that path too.
func TestASessionCarryingItsPoliciesIsStillBoundToItsRole(t *testing.T) {
ctx := context.Background()
m := setupIntegratedIAMSystem(t)
original, err := m.GetRole(ctx, "S3ReadOnlyRole")
require.NoError(t, err)
require.NotEmpty(t, original.AttachedPolicies, "precondition: the role attaches a policy")
resp, err := m.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
WebIdentityToken: createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key"),
RoleSessionName: "binding-test",
})
require.NoError(t, err)
allowed := func() bool {
ok, _ := m.IsActionAllowed(ctx, &ActionRequest{
Principal: resp.AssumedRoleUser.Arn,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/file.txt",
SessionToken: resp.Credentials.SessionToken,
PolicyNames: original.AttachedPolicies,
})
return ok
}
require.True(t, allowed(), "precondition: the session works")
require.NoError(t, m.DeleteRole(ctx, "S3ReadOnlyRole"))
assert.False(t, allowed(), "the session's embedded policies outlived its role's deletion")
recreated := *original
recreated.RoleId = ""
require.NoError(t, m.CreateRole(ctx, "", "S3ReadOnlyRole", &recreated))
assert.False(t, allowed(), "the session's embedded policies work again under a new role of the same name")
}
func TestStaticRoleIDIsStableAndRuntimeIDsAreUnique(t *testing.T) {
trusting := func(principal string) *policy.PolicyDocument {
return &policy.PolicyDocument{Version: "2012-10-17", Statement: []policy.Statement{{
Effect: "Allow", Action: []string{"sts:AssumeRoleWithWebIdentity"},
Principal: map[string]interface{}{"Federated": principal},
}}}
}
app := &RoleDefinition{RoleName: "app", TrustPolicy: trusting("https://a.example")}
restarted := &RoleDefinition{RoleName: "app", TrustPolicy: trusting("https://a.example")}
replaced := &RoleDefinition{RoleName: "app", TrustPolicy: trusting("https://b.example")}
assert.Equal(t, StaticRoleID(app), StaticRoleID(restarted), "a config-file role must keep its ID across restarts")
assert.NotEqual(t, StaticRoleID(app), StaticRoleID(replaced), "a different role under the same name inherits the old one's ID")
assert.NotEqual(t, StaticRoleID(app), StaticRoleID(&RoleDefinition{RoleName: "other", TrustPolicy: trusting("https://a.example")}))
assert.Regexp(t, `^AROA[A-Z0-9]{17}$`, StaticRoleID(app))
assert.NotEqual(t, NewRoleID(), NewRoleID())
assert.Regexp(t, `^AROA[A-Z2-7]{17}$`, NewRoleID())
}
// replacedAfterFirstReadStore serves the stored role on its first read and a
// replacement of the same name afterwards: a role replaced while a request
// is being authorized.
type replacedAfterFirstReadStore struct {
RoleStore
reads int
replacement *RoleDefinition
}
func (s *replacedAfterFirstReadStore) GetRole(ctx context.Context, addr, name string) (*RoleDefinition, error) {
s.reads++
if s.reads > 1 && name == s.replacement.RoleName {
return copyRoleDefinition(s.replacement), nil
}
return s.RoleStore.GetRole(ctx, addr, name)
}
// The session's binding is checked against one definition of its role, and
// that definition's policies are the ones evaluated: a replacement read in
// between must not lend the session its permissions.
func TestAuthorizationEvaluatesTheRoleTheBindingCheckSaw(t *testing.T) {
ctx := context.Background()
m := setupIntegratedIAMSystem(t)
require.NoError(t, m.CreatePolicy(ctx, "", "S3WritePolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow", Action: []string{"s3:PutObject"},
Resource: []string{"arn:aws:s3:::test-bucket/*"},
}},
}))
resp, err := m.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
WebIdentityToken: createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key"),
RoleSessionName: "snapshot-test",
})
require.NoError(t, err)
original, err := m.GetRole(ctx, "S3ReadOnlyRole")
require.NoError(t, err)
replacement := *original
replacement.RoleId = NewRoleID()
replacement.AttachedPolicies = []string{"S3WritePolicy"}
m.roleStore = &replacedAfterFirstReadStore{RoleStore: m.roleStore, replacement: &replacement}
allowed, _ := m.IsActionAllowed(ctx, &ActionRequest{
Principal: resp.AssumedRoleUser.Arn,
Action: "s3:PutObject",
Resource: "arn:aws:s3:::test-bucket/file.txt",
SessionToken: resp.Credentials.SessionToken,
})
assert.False(t, allowed, "the session was authorized by the replacement role's policies")
}
+11 -17
View File
@@ -6,27 +6,21 @@ func intPtr(v int64) *int64 { return &v }
func TestCapDurationByRole(t *testing.T) {
cases := []struct {
name string
requested *int64
roleMax int64
defaultSec int64
serviceMaxSec int64
want *int64
name string
requested *int64
roleMax int64
want *int64
}{
{"no cap, no request -> nil keeps service default", nil, 0, 3600, 43200, nil},
{"no cap, with request", intPtr(7200), 0, 3600, 43200, intPtr(7200)},
{"cap below default, no request -> cap", nil, 1800, 3600, 43200, intPtr(1800)},
{"cap above default, no request -> nil", nil, 43200, 3600, 43200, nil},
{"request below cap -> request", intPtr(1800), 3600, 900, 43200, intPtr(1800)},
{"request equal cap -> request", intPtr(3600), 3600, 900, 43200, intPtr(3600)},
{"request above cap -> cap", intPtr(43200), 3600, 900, 43200, intPtr(3600)},
{"default above service max -> service cap materialized", nil, 0, 7200, 3600, intPtr(3600)},
{"role bound above service max -> service cap still applies", nil, 40000, 43200, 3600, intPtr(3600)},
{"role bound below service floor -> tightest issuable", nil, 500, 3600, 43200, intPtr(900)},
{"no cap, no request", nil, 0, nil},
{"no cap, with request", intPtr(7200), 0, intPtr(7200)},
{"cap only, no request -> nil so STS default applies", nil, 3600, nil},
{"request below cap -> request", intPtr(1800), 3600, intPtr(1800)},
{"request equal cap -> request", intPtr(3600), 3600, intPtr(3600)},
{"request above cap -> cap", intPtr(43200), 3600, intPtr(3600)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := capDurationByRole(tc.requested, tc.roleMax, tc.defaultSec, tc.serviceMaxSec)
got := capDurationByRole(tc.requested, tc.roleMax)
switch {
case got == nil && tc.want == nil:
return
+36 -387
View File
@@ -3,10 +3,7 @@ package integration
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
"sync"
"time"
@@ -16,38 +13,9 @@ import (
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ErrRoleNotFound is wrapped by every RoleStore's GetRole when the role does
// not exist, so callers can tell a missing role from a store that could not
// be read.
var ErrRoleNotFound = errors.New("role not found")
// ErrRoleStatic refuses a change to a role defined in the server's IAM config
// file: change it there instead.
var ErrRoleStatic = errors.New("role is defined in the IAM config file")
// ErrRoleExists is returned by an UpdateRole whose update refuses a role that
// already exists (CreateRole).
var ErrRoleExists = errors.New("role already exists")
// RoleUpdate computes a role's new definition from its current one, nil when
// the role does not exist. It returns nil to delete the role, and an error to
// leave it unchanged. It may run more than once: it is called again with the
// fresh definition when another writer changed the role in between.
type RoleUpdate func(current *RoleDefinition) (*RoleDefinition, error)
// maxRoleUpdateAttempts bounds UpdateRole's retries under contention.
const maxRoleUpdateAttempts = 10
// errRoleUpdateContended is returned when the role kept changing under
// UpdateRole for maxRoleUpdateAttempts reads.
var errRoleUpdateContended = errors.New("role changed concurrently; retry")
// RoleStore defines the interface for storing IAM role definitions
type RoleStore interface {
// StoreRole stores a role definition (filerAddress ignored for memory stores)
@@ -61,14 +29,6 @@ type RoleStore interface {
// DeleteRole deletes a role definition (filerAddress ignored for memory stores)
DeleteRole(ctx context.Context, filerAddress string, roleName string) error
// UpdateRole replaces a role with update's result, atomically: the write
// lands only if the role is still as update saw it, absent included, and
// update is retried against the current role otherwise. Every change the
// IAM API and the filer IAM service make to a role goes through it, so
// writers on different servers neither lose each other's changes nor bring
// back a role deleted in between.
UpdateRole(ctx context.Context, filerAddress string, roleName string, update RoleUpdate) error
}
// MemoryRoleStore implements RoleStore using in-memory storage
@@ -112,7 +72,7 @@ func (m *MemoryRoleStore) GetRole(ctx context.Context, filerAddress string, role
role, exists := m.roles[roleName]
if !exists {
return nil, fmt.Errorf("%w: %s", ErrRoleNotFound, roleName)
return nil, fmt.Errorf("role not found: %s", roleName)
}
// Return a copy to prevent external modifications
@@ -145,26 +105,6 @@ func (m *MemoryRoleStore) DeleteRole(ctx context.Context, filerAddress string, r
return nil
}
// UpdateRole applies update under the store's lock (filerAddress ignored for
// memory store).
func (m *MemoryRoleStore) UpdateRole(ctx context.Context, filerAddress string, roleName string, update RoleUpdate) error {
if roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
m.mutex.Lock()
defer m.mutex.Unlock()
next, err := update(copyRoleDefinition(m.roles[roleName]))
if err != nil {
return err
}
if next == nil {
delete(m.roles, roleName)
return nil
}
m.roles[roleName] = copyRoleDefinition(next)
return nil
}
// copyRoleDefinition creates a deep copy of a role definition
func copyRoleDefinition(original *RoleDefinition) *RoleDefinition {
if original == nil {
@@ -172,13 +112,9 @@ func copyRoleDefinition(original *RoleDefinition) *RoleDefinition {
}
copied := &RoleDefinition{
RoleName: original.RoleName,
RoleArn: original.RoleArn,
Description: original.Description,
MaxSessionDuration: original.MaxSessionDuration,
Source: original.Source,
CreatedAt: original.CreatedAt,
RoleId: original.RoleId,
RoleName: original.RoleName,
RoleArn: original.RoleArn,
Description: original.Description,
}
// Deep copy trust policy if it exists
@@ -199,10 +135,6 @@ func copyRoleDefinition(original *RoleDefinition) *RoleDefinition {
return copied
}
// roleListPageSize is the number of entries FilerRoleStore.ListRoles asks the
// filer for per page.
const roleListPageSize = 1000
// FilerRoleStore implements RoleStore using SeaweedFS filer
type FilerRoleStore struct {
grpcDialOption grpc.DialOption
@@ -281,139 +213,6 @@ func (f *FilerRoleStore) StoreRole(ctx context.Context, filerAddress string, rol
})
}
// UpdateRole reads the role's entry, applies update, and writes the result on
// the condition that the entry is unchanged since the read — absent, when the
// role did not exist — so the filer refuses a write racing another writer's
// change or delete, and update is applied again to what that writer left. A
// delete is made on the same condition, so it removes the role update saw and
// not one written after it.
func (f *FilerRoleStore) UpdateRole(ctx context.Context, filerAddress string, roleName string, update RoleUpdate) error {
if filerAddress == "" && f.filerAddressProvider != nil {
filerAddress = f.filerAddressProvider()
}
if filerAddress == "" {
return fmt.Errorf("filer address is required for FilerRoleStore")
}
if roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
for attempt := 0; attempt < maxRoleUpdateAttempts; attempt++ {
var entry *filer_pb.Entry
var current *RoleDefinition
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: f.basePath,
Name: f.getRoleFileName(roleName),
})
switch {
case errors.Is(err, filer_pb.ErrNotFound):
case err != nil:
return fmt.Errorf("lookup role %s: %w", roleName, err)
case resp.Entry != nil:
entry = resp.Entry
current = &RoleDefinition{}
if err := json.Unmarshal(entry.Content, current); err != nil {
return fmt.Errorf("failed to deserialize role %s: %v", roleName, err)
}
}
next, err := update(current)
if err != nil {
return err
}
if next == nil {
if entry == nil {
return nil
}
deleted, err := f.deleteRoleEntryIfUnchanged(ctx, client, entry)
if err != nil {
return fmt.Errorf("failed to delete role %s: %w", roleName, err)
}
if !deleted {
glog.V(3).Infof("Role %s changed before its delete; retrying", roleName)
continue
}
return nil
}
roleData, err := json.MarshalIndent(next, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize role: %v", err)
}
clause := &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}
if entry != nil {
clause = &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry}
}
now := time.Now().Unix()
created, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: f.basePath,
Entry: &filer_pb.Entry{
Name: f.getRoleFileName(roleName),
Attributes: &filer_pb.FuseAttributes{
Mtime: now,
Crtime: now,
FileMode: uint32(0600),
},
Content: roleData,
},
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{clause}},
})
if isRoleWriteConflict(created, err) {
glog.V(3).Infof("Role %s changed during update; retrying", roleName)
continue
}
if err != nil {
return fmt.Errorf("failed to store role %s: %v", roleName, err)
}
if created.Error != "" {
return fmt.Errorf("failed to store role %s: %s", roleName, created.Error)
}
return nil
}
return fmt.Errorf("update role %s: %w", roleName, errRoleUpdateContended)
})
}
// deleteRoleEntryIfUnchanged deletes the role's entry if it still equals
// entry, reporting false when it changed. The delete is routed and locked as
// the conditional CreateEntry of the same path is, so the two serialize.
func (f *FilerRoleStore) deleteRoleEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
fullPath := f.basePath + "/" + entry.Name
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
LockKey: fullPath,
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
}}},
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_DELETE, Directory: f.basePath, Name: entry.Name, IsDeleteData: true,
}},
})
if err != nil {
if status.Code(err) == codes.FailedPrecondition {
return false, nil
}
return false, err
}
if resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED {
return false, nil
}
if resp.Error != "" {
return false, errors.New(resp.Error)
}
return true, nil
}
// isRoleWriteConflict reports a write the filer refused because its condition
// no longer held: in the response, or as FailedPrecondition when the write
// was forwarded to the entry's owner filer.
func isRoleWriteConflict(resp *filer_pb.CreateEntryResponse, err error) bool {
if err != nil {
return status.Code(err) == codes.FailedPrecondition
}
return resp != nil && resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED
}
// GetRole retrieves a role definition from filer
func (f *FilerRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
// Use provider function if filerAddress is not provided
@@ -435,16 +234,13 @@ func (f *FilerRoleStore) GetRole(ctx context.Context, filerAddress string, roleN
}
glog.V(3).Infof("Looking up role %s", roleName)
response, err := filer_pb.LookupEntry(ctx, client, request)
if errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("%w: %s", ErrRoleNotFound, roleName)
}
response, err := client.LookupDirectoryEntry(ctx, request)
if err != nil {
return fmt.Errorf("lookup role %s: %w", roleName, err)
return fmt.Errorf("role not found: %v", err)
}
if response.Entry == nil {
return fmt.Errorf("%w: %s", ErrRoleNotFound, roleName)
return fmt.Errorf("role not found")
}
roleData = response.Entry.Content
@@ -474,50 +270,46 @@ func (f *FilerRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]
return nil, fmt.Errorf("filer address is required for FilerRoleStore")
}
// Page through the directory, and fail on a broken stream rather than
// return what arrived: a truncated list would let DeletePolicy miss a role
// that still attaches the policy (RolesAttachingPolicy).
var roleNames []string
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
request := &filer_pb.ListEntriesRequest{
Directory: f.basePath,
Prefix: "",
StartFromFileName: "",
InclusiveStartFrom: false,
Limit: 1000, // Process in batches of 1000
}
glog.V(3).Infof("Listing roles in %s", f.basePath)
startFrom := ""
stream, err := client.ListEntries(ctx, request)
if err != nil {
return fmt.Errorf("failed to list roles: %v", err)
}
for {
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
Directory: f.basePath,
StartFromFileName: startFrom,
Limit: roleListPageSize,
})
resp, err := stream.Recv()
if err != nil {
return err
break // End of stream or error
}
received := 0
for {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if resp.Entry == nil {
continue
}
received++
startFrom = resp.Entry.Name
if resp.Entry.IsDirectory {
continue
}
if name, ok := strings.CutSuffix(resp.Entry.Name, ".json"); ok {
roleNames = append(roleNames, name)
}
if resp.Entry == nil || resp.Entry.IsDirectory {
continue
}
if received < roleListPageSize {
return nil
// Extract role name from filename
filename := resp.Entry.Name
if strings.HasSuffix(filename, ".json") {
roleName := strings.TrimSuffix(filename, ".json")
roleNames = append(roleNames, roleName)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list roles: %w", err)
return nil, err
}
return roleNames, nil
@@ -596,146 +388,3 @@ type CachedFilerRoleStoreConfig struct {
ListTTL string `json:"listTtl,omitempty"` // e.g., "1m", "30s"
MaxCacheSize int `json:"maxCacheSize,omitempty"` // Maximum number of cached roles
}
// RolesAttachingPolicy returns the names of the roles that attach the policy.
// A policy is attached to a role by name, so deleting it while attached would
// let a policy created later under the same name take effect on the role;
// callers refuse the delete instead, as AWS does (DeleteConflict).
//
// It sees the stored roles, which every server sharing the store sees, and
// this server's config-file roles. A role defined only in another server's
// config file is invisible here: servers sharing a role store may have
// different config files, but a config-file role that attaches a managed
// (API-created) policy is protected only on the servers whose file defines it.
// Keep such roles in every server's file, or attach only config-file policies
// to config-file roles.
func RolesAttachingPolicy(ctx context.Context, store RoleStore, policyName string) ([]string, error) {
names, err := store.ListRoles(ctx, "")
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
var attaching []string
for _, name := range names {
role, err := store.GetRole(ctx, "", name)
if errors.Is(err, ErrRoleNotFound) {
continue // deleted between list and read
}
if err != nil {
return nil, fmt.Errorf("get role %s: %w", name, err)
}
for _, attached := range role.AttachedPolicies {
if attached == policyName {
attaching = append(attaching, name)
break
}
}
}
return attaching, nil
}
// MaxManagedPoliciesPerRole caps the managed policies attached to one role,
// as AWS's default quota does (and as MaxManagedPoliciesPerUser does for users).
const MaxManagedPoliciesPerRole = 10
var roleNamePattern = regexp.MustCompile(`^[\w+=,.@-]{1,64}$`)
// ValidateRoleName checks a role name against AWS's rules. A role is stored as
// <name>.json in the filer, so the rules also keep a name from leaving the
// role store's directory.
func ValidateRoleName(name string) error {
if !roleNamePattern.MatchString(name) {
return fmt.Errorf("invalid role name %q: must be 1-64 characters of letters, digits and +=,.@_-", name)
}
return nil
}
// staticRoleOverlay serves the IAM config file's roles from memory beside a
// persistent store, which never holds them (see IAMManager.LoadStaticRoles).
// A role stored under the same name takes precedence, as a stored OIDC
// provider does over a config-file one; deleting it brings the config-file
// role back. A config-file role itself cannot be stored over or deleted.
type staticRoleOverlay struct {
static map[string]*RoleDefinition
inner RoleStore
}
// storedRoleExists reports whether the store itself holds the role.
func (o *staticRoleOverlay) storedRoleExists(ctx context.Context, filerAddress, roleName string) (bool, error) {
_, err := o.inner.GetRole(ctx, filerAddress, roleName)
if errors.Is(err, ErrRoleNotFound) {
return false, nil
}
return err == nil, err
}
func (o *staticRoleOverlay) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
if _, ok := o.static[roleName]; ok {
stored, err := o.storedRoleExists(ctx, filerAddress, roleName)
if err != nil {
return err
}
if !stored {
return fmt.Errorf("%w: %s", ErrRoleStatic, roleName)
}
}
return o.inner.StoreRole(ctx, filerAddress, roleName, role)
}
// UpdateRole refuses to create a stored role over a config-file role, as
// StoreRole does; a stored role of that name may be changed.
func (o *staticRoleOverlay) UpdateRole(ctx context.Context, filerAddress string, roleName string, update RoleUpdate) error {
_, static := o.static[roleName]
return o.inner.UpdateRole(ctx, filerAddress, roleName, func(current *RoleDefinition) (*RoleDefinition, error) {
if current == nil && static {
return nil, fmt.Errorf("%w: %s", ErrRoleStatic, roleName)
}
return update(current)
})
}
func (o *staticRoleOverlay) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
role, err := o.inner.GetRole(ctx, filerAddress, roleName)
if errors.Is(err, ErrRoleNotFound) {
if static, ok := o.static[roleName]; ok {
return copyRoleDefinition(static), nil
}
}
return role, err
}
func (o *staticRoleOverlay) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
names, err := o.inner.ListRoles(ctx, filerAddress)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(names))
for _, name := range names {
seen[name] = true
}
for name := range o.static {
if !seen[name] {
names = append(names, name)
}
}
return names, nil
}
func (o *staticRoleOverlay) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
if _, ok := o.static[roleName]; ok {
stored, err := o.storedRoleExists(ctx, filerAddress, roleName)
if err != nil {
return err
}
if !stored {
return fmt.Errorf("%w: %s", ErrRoleStatic, roleName)
}
}
return o.inner.DeleteRole(ctx, filerAddress, roleName)
}
// ClearCache forwards cache invalidation to the store underneath.
func (o *staticRoleOverlay) ClearCache() {
if cached, ok := o.inner.(interface{ ClearCache() }); ok {
cached.ClearCache()
}
}
@@ -1,286 +0,0 @@
package integration
import (
"context"
"errors"
"fmt"
"net"
"slices"
"sort"
"strconv"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
// roleStoreTestFiler is a filer holding one directory. It evaluates the write
// conditions FilerRoleStore sends as the filer does, and can run another
// writer between a role's read and its write (afterLookup) or break a listing
// stream partway (failListAfter).
type roleStoreTestFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
mu sync.Mutex
entries map[string]*filer_pb.Entry
afterLookup func()
failListAfter int
}
func (s *roleStoreTestFiler) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
s.mu.Lock()
entry, found := s.entries[req.Name]
hook := s.afterLookup
s.afterLookup = nil
s.mu.Unlock()
if hook != nil {
defer hook()
}
if !found {
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
}
return &filer_pb.LookupDirectoryEntryResponse{Entry: proto.Clone(entry).(*filer_pb.Entry)}, nil
}
func (s *roleStoreTestFiler) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
current, exists := s.entries[req.Entry.Name]
for _, c := range req.GetCondition().GetClauses() {
var ok bool
switch c.Kind {
case filer_pb.WriteCondition_IF_NOT_EXISTS:
ok = !exists
case filer_pb.WriteCondition_IF_ENTRY_EQUAL:
ok = exists && proto.Equal(current, c.ExpectedEntry)
default:
return nil, fmt.Errorf("unexpected condition %v", c.Kind)
}
if !ok {
return &filer_pb.CreateEntryResponse{Error: "precondition failed", ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED}, nil
}
}
s.entries[req.Entry.Name] = proto.Clone(req.Entry).(*filer_pb.Entry)
return &filer_pb.CreateEntryResponse{}, nil
}
// ObjectTransaction applies the conditional delete FilerRoleStore sends.
func (s *roleStoreTestFiler) ObjectTransaction(_ context.Context, req *filer_pb.ObjectTransactionRequest) (*filer_pb.ObjectTransactionResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(req.Mutations) != 1 || req.Mutations[0].Type != filer_pb.ObjectMutation_DELETE {
return nil, fmt.Errorf("unexpected mutations %v", req.Mutations)
}
name := req.Mutations[0].Name
current, exists := s.entries[name]
for _, c := range req.GetCondition().GetClauses() {
if c.Kind != filer_pb.WriteCondition_IF_ENTRY_EQUAL {
return nil, fmt.Errorf("unexpected condition %v", c.Kind)
}
if !exists || !proto.Equal(current, c.ExpectedEntry) {
return &filer_pb.ObjectTransactionResponse{Error: "precondition failed", ErrorCode: filer_pb.FilerError_PRECONDITION_FAILED}, nil
}
}
delete(s.entries, name)
return &filer_pb.ObjectTransactionResponse{}, nil
}
func (s *roleStoreTestFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
s.mu.Lock()
var names []string
for name := range s.entries {
if name > req.StartFromFileName {
names = append(names, name)
}
}
sort.Strings(names)
if req.Limit > 0 && len(names) > int(req.Limit) {
names = names[:req.Limit]
}
page := make([]*filer_pb.Entry, 0, len(names))
for _, name := range names {
page = append(page, proto.Clone(s.entries[name]).(*filer_pb.Entry))
}
failAfter := s.failListAfter
s.mu.Unlock()
for i, entry := range page {
if failAfter > 0 && i == failAfter {
return status.Error(codes.Unavailable, "filer went away")
}
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
return err
}
}
return nil
}
func (s *roleStoreTestFiler) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.entries, req.Name)
return &filer_pb.DeleteEntryResponse{}, nil
}
func newTestFilerRoleStore(t *testing.T) (*FilerRoleStore, *roleStoreTestFiler) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
filer := &roleStoreTestFiler{entries: map[string]*filer_pb.Entry{}}
server := pb.NewGrpcServer()
filer_pb.RegisterSeaweedFilerServer(server, filer)
go func() { _ = server.Serve(lis) }()
t.Cleanup(func() {
server.Stop()
_ = lis.Close()
})
host, port, err := net.SplitHostPort(lis.Addr().String())
require.NoError(t, err)
grpcPort, err := strconv.Atoi(port)
require.NoError(t, err)
store, err := NewFilerRoleStore(nil, func() string { return string(pb.NewServerAddress(host, 1, grpcPort)) })
require.NoError(t, err)
store.grpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
return store, filer
}
func attachPolicy(policyName string) RoleUpdate {
return func(current *RoleDefinition) (*RoleDefinition, error) {
if current == nil {
return nil, ErrRoleNotFound
}
current.AttachedPolicies = append(current.AttachedPolicies, policyName)
return current, nil
}
}
func createRole(roleID string) RoleUpdate {
return func(current *RoleDefinition) (*RoleDefinition, error) {
if current != nil {
return nil, ErrRoleExists
}
return &RoleDefinition{RoleName: "app", RoleId: roleID}, nil
}
}
// Two servers changing one role: the change written second is applied to
// what the first left, not to the role as it read it.
func TestFilerRoleUpdateIsNotLostToAConcurrentChange(t *testing.T) {
ctx := context.Background()
store, filer := newTestFilerRoleStore(t)
require.NoError(t, store.UpdateRole(ctx, "", "app", createRole("AROA1")))
filer.afterLookup = func() { assert.NoError(t, store.UpdateRole(ctx, "", "app", attachPolicy("peer"))) }
require.NoError(t, store.UpdateRole(ctx, "", "app", attachPolicy("mine")))
role, err := store.GetRole(ctx, "", "app")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"peer", "mine"}, role.AttachedPolicies, "one server's change was lost")
}
// A change racing a delete must not write the role back.
func TestFilerRoleUpdateDoesNotReviveADeletedRole(t *testing.T) {
ctx := context.Background()
store, filer := newTestFilerRoleStore(t)
require.NoError(t, store.UpdateRole(ctx, "", "app", createRole("AROA1")))
filer.afterLookup = func() { assert.NoError(t, store.DeleteRole(ctx, "", "app")) }
err := store.UpdateRole(ctx, "", "app", attachPolicy("mine"))
assert.ErrorIs(t, err, ErrRoleNotFound)
_, err = store.GetRole(ctx, "", "app")
assert.ErrorIs(t, err, ErrRoleNotFound, "the deleted role was written back")
}
// Of two creates of one name, the second sees the first's role.
func TestFilerRoleCreateRefusesARoleCreatedConcurrently(t *testing.T) {
ctx := context.Background()
store, filer := newTestFilerRoleStore(t)
filer.afterLookup = func() { assert.NoError(t, store.UpdateRole(ctx, "", "app", createRole("AROA-FIRST"))) }
err := store.UpdateRole(ctx, "", "app", createRole("AROA-SECOND"))
assert.ErrorIs(t, err, ErrRoleExists)
role, err := store.GetRole(ctx, "", "app")
require.NoError(t, err)
assert.Equal(t, "AROA-FIRST", role.RoleId, "the second create replaced the first role")
}
var errRoleHasPolicies = errors.New("role has attached policies")
// deleteUnattached deletes the role only if it attaches no policy, as
// DeleteRole requires.
func deleteUnattached(current *RoleDefinition) (*RoleDefinition, error) {
if current == nil {
return nil, ErrRoleNotFound
}
if len(current.AttachedPolicies) > 0 {
return nil, errRoleHasPolicies
}
return nil, nil
}
func TestFilerRoleUpdateCanDeleteTheRole(t *testing.T) {
ctx := context.Background()
store, _ := newTestFilerRoleStore(t)
require.NoError(t, store.UpdateRole(ctx, "", "app", createRole("AROA1")))
require.NoError(t, store.UpdateRole(ctx, "", "app", deleteUnattached))
_, err := store.GetRole(ctx, "", "app")
assert.ErrorIs(t, err, ErrRoleNotFound)
}
// A delete is decided against the role it removes: a policy attached between
// the check and the delete makes it refuse, not delete the role anyway.
func TestFilerRoleDeleteIsDecidedAgainstTheRoleItRemoves(t *testing.T) {
ctx := context.Background()
store, filer := newTestFilerRoleStore(t)
require.NoError(t, store.UpdateRole(ctx, "", "app", createRole("AROA1")))
filer.afterLookup = func() { assert.NoError(t, store.UpdateRole(ctx, "", "app", attachPolicy("peer"))) }
err := store.UpdateRole(ctx, "", "app", deleteUnattached)
assert.ErrorIs(t, err, errRoleHasPolicies)
role, err := store.GetRole(ctx, "", "app")
require.NoError(t, err, "the role was deleted although a policy was attached to it")
assert.Equal(t, []string{"peer"}, role.AttachedPolicies)
}
func TestFilerRoleListingPagesPastTheFirstThousand(t *testing.T) {
store, filer := newTestFilerRoleStore(t)
for i := range roleListPageSize + 1 {
name := fmt.Sprintf("role-%04d.json", i)
filer.entries[name] = &filer_pb.Entry{Name: name}
}
filer.entries["role-0500"] = &filer_pb.Entry{Name: "role-0500", IsDirectory: true}
names, err := store.ListRoles(context.Background(), "")
require.NoError(t, err)
assert.Len(t, names, roleListPageSize+1)
assert.True(t, slices.Contains(names, fmt.Sprintf("role-%04d", roleListPageSize)), "the role past the first page is missing")
}
// A listing cut short must fail: DeletePolicy decides from it whether any
// role still attaches the policy.
func TestFilerRoleListingFailsOnABrokenStream(t *testing.T) {
store, filer := newTestFilerRoleStore(t)
for i := range 5 {
name := fmt.Sprintf("role-%d.json", i)
filer.entries[name] = &filer_pb.Entry{Name: name}
}
filer.failListAfter = 3
_, err := store.ListRoles(context.Background(), "")
require.Error(t, err, "a partial listing was returned as complete")
attaching, err := RolesAttachingPolicy(context.Background(), store, "read")
assert.Error(t, err)
assert.Nil(t, attaching)
assert.False(t, errors.Is(err, ErrRoleNotFound))
}
@@ -1,14 +0,0 @@
package integration
import (
"context"
"errors"
"testing"
)
func TestMemoryRoleStoreWrapsErrRoleNotFound(t *testing.T) {
_, err := NewMemoryRoleStore().GetRole(context.Background(), "", "missing")
if !errors.Is(err, ErrRoleNotFound) {
t.Fatalf("missing role error does not wrap ErrRoleNotFound: %v", err)
}
}
@@ -1,196 +0,0 @@
package integration
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// persistentTestRoleStore stands in for a role store that outlives the process
// and may be shared (the filer store): anything but *MemoryRoleStore is
// treated as persistent.
type persistentTestRoleStore struct {
*MemoryRoleStore
cleared int
}
func (s *persistentTestRoleStore) ClearCache() { s.cleared++ }
// startRoleServer initializes a manager as an S3 server would at boot, with the
// given role store and config file roles.
func startRoleServer(t *testing.T, store RoleStore, staticRoles ...string) *IAMManager {
t.Helper()
mgr := NewIAMManager()
require.NoError(t, mgr.Initialize(persistTestConfig(), func() string { return "localhost:8888" }))
mgr.roleStore = store
var defs []*RoleDefinition
for _, name := range staticRoles {
defs = append(defs, &RoleDefinition{RoleName: name})
}
mgr.LoadStaticRoles(context.Background(), defs)
return mgr
}
func roleNames(t *testing.T, store RoleStore) map[string]bool {
t.Helper()
names, err := store.ListRoles(context.Background(), "")
require.NoError(t, err)
out := map[string]bool{}
for _, n := range names {
out[n] = true
}
return out
}
// A persistent store never receives the config file's roles: it outlives the
// file and may be shared with servers whose files differ.
func TestPersistentRoleStoreNeverHoldsConfigFileRoles(t *testing.T) {
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
mgr := startRoleServer(t, store, "from-file")
assert.Empty(t, roleNames(t, store), "a config-file role was written to the persistent store")
assert.True(t, roleNames(t, mgr.GetRoleStore())["from-file"], "the config-file role is not listed")
role, err := mgr.GetRole(context.Background(), "from-file")
require.NoError(t, err)
assert.Equal(t, RoleSourceStaticConfig, role.Source)
assert.Equal(t, StaticRoleID(&RoleDefinition{RoleName: "from-file"}), role.RoleId)
}
// Servers sharing a store, with different config files, must not remove or
// replace each other's roles — including a zero-config server.
func TestServersSharingARoleStoreKeepEachOthersRoles(t *testing.T) {
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
configured := startRoleServer(t, store, "from-file")
require.NoError(t, configured.CreateRole(context.Background(), "", "created-at-runtime", &RoleDefinition{}))
zeroConfig := startRoleServer(t, store)
assert.True(t, roleNames(t, store)["created-at-runtime"], "a peer's start removed a role created at runtime")
assert.True(t, roleNames(t, configured.GetRoleStore())["from-file"], "the configured server lost its config-file role")
_, err := zeroConfig.GetRole(context.Background(), "from-file")
assert.ErrorIs(t, err, ErrRoleNotFound, "a server sees a role only its peer's config file defines")
_, err = zeroConfig.GetRole(context.Background(), "created-at-runtime")
assert.NoError(t, err, "the peer does not see the shared role created at runtime")
}
// Removing a role from the config file removes it at the next start.
func TestRemovingARoleFromTheConfigFileRemovesIt(t *testing.T) {
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
startRoleServer(t, store, "from-file")
restarted := startRoleServer(t, store) // the file no longer lists it
_, err := restarted.GetRole(context.Background(), "from-file")
assert.ErrorIs(t, err, ErrRoleNotFound, "a role removed from the config file is still served")
}
// The store refuses to replace or delete a role the config file defines; the
// file is where it changes.
func TestConfigFileRolesCannotBeReplacedOrDeletedThroughTheStore(t *testing.T) {
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
mgr := startRoleServer(t, store, "from-file")
err := mgr.CreateRole(context.Background(), "", "from-file", &RoleDefinition{})
assert.ErrorIs(t, err, ErrRoleStatic)
assert.ErrorIs(t, mgr.DeleteRole(context.Background(), "from-file"), ErrRoleStatic)
assert.Empty(t, roleNames(t, store), "a refused change still wrote to the store")
}
// An in-memory store keeps its behaviour: the config file's roles are records
// in it.
func TestInMemoryRoleStoreStillHoldsConfigFileRoles(t *testing.T) {
store := NewMemoryRoleStore()
startRoleServer(t, store, "from-file")
assert.True(t, roleNames(t, store)["from-file"])
}
// Invalidating the role cache reaches the store beneath the config-file roles.
func TestRoleCacheInvalidationReachesTheStoreBeneathTheOverlay(t *testing.T) {
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
mgr := startRoleServer(t, store, "from-file")
mgr.InvalidateRoleCache()
assert.Equal(t, 1, store.cleared)
}
func TestRoleCopiesKeepSource(t *testing.T) {
role := &RoleDefinition{RoleName: "r", Source: RoleSourceStaticConfig}
assert.Equal(t, RoleSourceStaticConfig, copyRoleDefinition(role).Source)
assert.Equal(t, RoleSourceStaticConfig, genericCopyRoleDefinition(role).Source)
}
// Config-file roles report no creation time: a time taken at load would
// change with every restart.
func TestConfigFileRolesReportNoCreationTime(t *testing.T) {
for name, store := range map[string]RoleStore{
"persistent": &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()},
"in-memory": NewMemoryRoleStore(),
} {
t.Run(name, func(t *testing.T) {
mgr := startRoleServer(t, store, "from-file")
role, err := mgr.GetRole(context.Background(), "from-file")
require.NoError(t, err)
assert.True(t, role.CreatedAt.IsZero())
})
}
}
// A role stored under a config-file role's name takes precedence, as a stored
// OIDC provider does; it can be changed, and deleting it brings the
// config-file role back.
func TestStoredRoleTakesPrecedenceOverTheConfigFileOne(t *testing.T) {
ctx := context.Background()
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
configured := startRoleServer(t, store, "shared-name")
peer := startRoleServer(t, store)
require.NoError(t, peer.CreateRole(ctx, "", "shared-name", &RoleDefinition{Description: "stored"}))
role, err := configured.GetRole(ctx, "shared-name")
require.NoError(t, err)
assert.Equal(t, "stored", role.Description, "the config-file role hides the stored one")
role.Description = "changed"
assert.NoError(t, configured.CreateRole(ctx, "", "shared-name", role), "the stored role cannot be changed")
require.NoError(t, configured.DeleteRole(ctx, "shared-name"))
role, err = configured.GetRole(ctx, "shared-name")
require.NoError(t, err)
assert.Equal(t, RoleSourceStaticConfig, role.Source, "deleting the stored role did not bring the config-file one back")
}
// A store installed through SetRoleStore behaves like one installed at
// startup: the config-file roles stay visible and protected, and it serves
// the roles it holds.
func TestSetRoleStoreInstallsLikeStartup(t *testing.T) {
mgr := startRoleServer(t, &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}, "from-file")
store := &persistentTestRoleStore{MemoryRoleStore: NewMemoryRoleStore()}
require.NoError(t, store.StoreRole(context.Background(), "", "stored", &RoleDefinition{RoleName: "stored"}))
mgr.SetRoleStore(store)
names := roleNames(t, mgr.GetRoleStore())
assert.True(t, names["from-file"], "the config-file role is no longer listed")
assert.True(t, names["stored"], "the new store's role is not listed")
assert.ErrorIs(t, mgr.GetRoleStore().DeleteRole(context.Background(), "", "from-file"), ErrRoleStatic)
assert.False(t, roleNames(t, store)["from-file"], "the config-file role was written into the new store")
}
// S3 servers watch the directory a filer-backed role store keeps roles in, so
// a store configured with its own basePath must report it, through the
// cache and the config-file overlay alike.
func TestRoleStoreDirectoryIsTheConfiguredBasePath(t *testing.T) {
provider := func() string { return "localhost:8888" }
cached, err := NewGenericCachedRoleStore(map[string]interface{}{"basePath": "/custom/roles"}, provider)
require.NoError(t, err)
mgr := startRoleServer(t, cached, "from-file")
assert.Equal(t, "/custom/roles", mgr.RoleStoreDirectory())
uncached, err := NewFilerRoleStore(nil, provider)
require.NoError(t, err)
mgr.SetRoleStore(uncached)
assert.Equal(t, "/etc/iam/roles", mgr.RoleStoreDirectory())
mgr.SetRoleStore(NewMemoryRoleStore())
assert.Empty(t, mgr.RoleStoreDirectory(), "a memory store has no directory")
}
+2 -5
View File
@@ -473,11 +473,8 @@ func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *Pol
return nil, fmt.Errorf("trust policy cannot be nil")
}
// A trust policy is deny-by-default, whatever the engine's DefaultEffect:
// a role may be assumed only by a principal a trust statement explicitly
// allows.
result := &EvaluationResult{
Effect: EffectDeny,
Effect: Effect(e.config.DefaultEffect),
EvaluationDetails: &EvaluationDetails{
Principal: evalCtx.Principal,
Action: evalCtx.Action,
@@ -514,7 +511,7 @@ func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *Pol
// AWS IAM evaluation logic:
// 1. If there's an explicit Deny, the result is Deny
// 2. If there's an Allow and no Deny, the result is Allow
// 3. Otherwise, the implicit deny stands
// 3. Otherwise, use the default effect
if explicitDeny {
result.Effect = EffectDeny
} else if hasAllow {
@@ -1,80 +0,0 @@
package policy
import (
"context"
"encoding/json"
"testing"
)
const subjectBoundTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow",
"Principal":{"Federated":"https://oidc.example"},"Action":["sts:AssumeRoleWithWebIdentity"],
"Condition":{"StringEquals":{"oidc:sub":"spiffe://example.org/ns/app/sa/app"}}}]}`
func webIdentityContext(sub string) *EvaluationContext {
return &EvaluationContext{
Principal: "web-identity-user",
Action: "sts:AssumeRoleWithWebIdentity",
Resource: "arn:aws:iam::role/app",
RequestContext: map[string]interface{}{
"aws:FederatedProvider": "https://oidc.example",
"oidc:iss": "https://oidc.example",
"oidc:sub": sub,
},
}
}
// A trust policy is deny-by-default. The engine's DefaultEffect decides
// requests no identity policy speaks to; it must not let a principal the trust
// policy does not allow assume the role.
func TestEvaluateTrustPolicyIsImplicitDenyWhateverTheDefaultEffect(t *testing.T) {
var trust PolicyDocument
if err := json.Unmarshal([]byte(subjectBoundTrustPolicy), &trust); err != nil {
t.Fatalf("parse test trust policy: %v", err)
}
for _, defaultEffect := range []string{"Allow", "Deny"} {
t.Run("defaultEffect="+defaultEffect, func(t *testing.T) {
engine := NewPolicyEngine()
if err := engine.Initialize(&PolicyEngineConfig{DefaultEffect: defaultEffect, StoreType: "memory"}); err != nil {
t.Fatalf("initialize policy engine: %v", err)
}
res, err := engine.EvaluateTrustPolicy(context.Background(), &trust, webIdentityContext("spiffe://example.org/ns/other/sa/x"))
if err != nil {
t.Fatalf("evaluate trust policy: %v", err)
}
if res.Effect != EffectDeny {
t.Errorf("a subject the trust policy does not allow got %s", res.Effect)
}
res, err = engine.EvaluateTrustPolicy(context.Background(), &trust, webIdentityContext("spiffe://example.org/ns/app/sa/app"))
if err != nil {
t.Fatalf("evaluate trust policy: %v", err)
}
if res.Effect != EffectAllow {
t.Errorf("the subject the trust policy allows got %s", res.Effect)
}
})
}
}
func TestEvaluateTrustPolicyExplicitDenyWins(t *testing.T) {
var trust PolicyDocument
doc := `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":{"Federated":"https://oidc.example"},"Action":["sts:AssumeRoleWithWebIdentity"]},
{"Effect":"Deny","Principal":{"Federated":"https://oidc.example"},"Action":["sts:AssumeRoleWithWebIdentity"],
"Condition":{"StringEquals":{"oidc:sub":"spiffe://example.org/ns/app/sa/app"}}}]}`
if err := json.Unmarshal([]byte(doc), &trust); err != nil {
t.Fatalf("parse test trust policy: %v", err)
}
engine := NewPolicyEngine()
if err := engine.Initialize(&PolicyEngineConfig{DefaultEffect: "Allow", StoreType: "memory"}); err != nil {
t.Fatalf("initialize policy engine: %v", err)
}
res, err := engine.EvaluateTrustPolicy(context.Background(), &trust, webIdentityContext("spiffe://example.org/ns/app/sa/app"))
if err != nil {
t.Fatalf("evaluate trust policy: %v", err)
}
if res.Effect != EffectDeny {
t.Errorf("explicit Deny did not win: %s", res.Effect)
}
}
-80
View File
@@ -532,83 +532,3 @@ type ListUserTagsResponse struct {
} `xml:"ListUserTagsResult"`
CommonResponse
}
// IAMRole is the Role element returned by the role actions.
type IAMRole struct {
Path string `xml:"Path"`
RoleName string `xml:"RoleName"`
RoleId string `xml:"RoleId"`
Arn string `xml:"Arn"`
CreateDate string `xml:"CreateDate,omitempty"`
AssumeRolePolicyDocument string `xml:"AssumeRolePolicyDocument,omitempty"`
Description string `xml:"Description,omitempty"`
MaxSessionDuration int64 `xml:"MaxSessionDuration,omitempty"`
}
// IAMAttachedPolicy is one element of ListAttachedRolePolicies.
type IAMAttachedPolicy struct {
PolicyName string `xml:"PolicyName"`
PolicyArn string `xml:"PolicyArn"`
}
// CreateRoleResponse is the response for CreateRole.
type CreateRoleResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateRoleResponse"`
CreateRoleResult struct {
Role IAMRole `xml:"Role"`
} `xml:"CreateRoleResult"`
CommonResponse
}
// GetRoleResponse is the response for GetRole.
type GetRoleResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRoleResponse"`
GetRoleResult struct {
Role IAMRole `xml:"Role"`
} `xml:"GetRoleResult"`
CommonResponse
}
// ListRolesResponse is the response for ListRoles.
type ListRolesResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolesResponse"`
ListRolesResult struct {
Roles []*IAMRole `xml:"Roles>member"`
IsTruncated bool `xml:"IsTruncated"`
} `xml:"ListRolesResult"`
CommonResponse
}
// DeleteRoleResponse is the response for DeleteRole.
type DeleteRoleResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRoleResponse"`
CommonResponse
}
// UpdateAssumeRolePolicyResponse is the response for UpdateAssumeRolePolicy.
type UpdateAssumeRolePolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAssumeRolePolicyResponse"`
CommonResponse
}
// AttachRolePolicyResponse is the response for AttachRolePolicy.
type AttachRolePolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AttachRolePolicyResponse"`
CommonResponse
}
// DetachRolePolicyResponse is the response for DetachRolePolicy.
type DetachRolePolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DetachRolePolicyResponse"`
CommonResponse
}
// ListAttachedRolePoliciesResponse is the response for ListAttachedRolePolicies.
type ListAttachedRolePoliciesResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAttachedRolePoliciesResponse"`
ListAttachedRolePoliciesResult struct {
AttachedPolicies []*IAMAttachedPolicy `xml:"AttachedPolicies>member"`
IsTruncated bool `xml:"IsTruncated"`
} `xml:"ListAttachedRolePoliciesResult"`
CommonResponse
}
+3 -12
View File
@@ -65,10 +65,9 @@ type STSSessionClaims struct {
TokenType string `json:"typ"` // token_type
// Role information
RoleArn string `json:"role"` // role_arn
RoleId string `json:"rid,omitempty"` // unique ID of the assumed role
AssumedRole string `json:"assumed"` // assumed_role_user
Principal string `json:"principal"` // principal_arn
RoleArn string `json:"role"` // role_arn
AssumedRole string `json:"assumed"` // assumed_role_user
Principal string `json:"principal"` // principal_arn
// Authorization data
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
@@ -137,7 +136,6 @@ func (c *STSSessionClaims) ToSessionInfo(credGen *CredentialGenerator) *SessionI
SessionId: c.SessionId,
SessionName: c.SessionName,
RoleArn: c.RoleArn,
RoleId: c.RoleId,
AssumedRoleUser: c.AssumedRole,
Principal: c.Principal,
Policies: c.Policies,
@@ -198,13 +196,6 @@ func (c *STSSessionClaims) WithRoleInfo(roleArn, assumedRole, principal string)
}
// WithPolicies sets the policies associated with this session
// WithRoleId binds the session to the assumed role's unique ID. An empty ID
// leaves the session unbound (roles that predate role IDs).
func (c *STSSessionClaims) WithRoleId(roleId string) *STSSessionClaims {
c.RoleId = roleId
return c
}
func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
c.Policies = policies
return c
+7 -23
View File
@@ -187,11 +187,6 @@ type AssumeRoleWithWebIdentityRequest struct {
// Policy is an optional session policy (optional)
Policy *string `json:"Policy,omitempty"`
// RoleId is the assumed role's unique ID, set by the IAM manager after it
// resolves the role. It is embedded in the session so the session is bound
// to this role and not to whatever role later holds the same name.
RoleId string `json:"-"`
}
// AssumeRoleWithCredentialsRequest represents a request to assume role with username/password
@@ -216,9 +211,6 @@ type AssumeRoleWithCredentialsRequest struct {
// Policy is an optional session policy (optional)
Policy *string `json:"Policy,omitempty"`
// RoleId is set by the IAM manager; see AssumeRoleWithWebIdentityRequest.
RoleId string `json:"-"`
}
// AssumeRoleResponse represents the response from assume role operations
@@ -312,10 +304,6 @@ type SessionInfo struct {
// ParentUser is the stable hashed identity (sub+iss) derived at federation time.
ParentUser string `json:"parentUser,omitempty"`
// RoleId is the unique ID of the role the session was issued for; empty
// for sessions issued before role IDs were recorded.
RoleId string `json:"roleId,omitempty"`
}
// NewSTSService creates a new STS service
@@ -616,7 +604,7 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
}
// 4. Calculate session duration
sessionDuration := s.CalculateSessionDuration(request.DurationSeconds)
sessionDuration := s.calculateSessionDuration(request.DurationSeconds)
expiresAt := time.Now().Add(sessionDuration)
// 5. Generate session ID and credentials
@@ -700,7 +688,6 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
WithSessionName(request.RoleSessionName).
WithRoleInfo(effectiveRoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
WithRoleId(request.RoleId).
WithIdentityProvider(provider.Name(), externalIdentity.UserID, externalIdentity.Issuer).
WithMaxDuration(sessionDuration).
WithRequestContext(requestContext)
@@ -773,16 +760,16 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass
}
// 4-7. Mint the session
return s.issueSession(request.RoleArn, request.RoleId, request.RoleSessionName, sessionPolicy,
return s.issueSession(request.RoleArn, request.RoleSessionName, sessionPolicy,
request.DurationSeconds, provider.Name(), externalIdentity.UserID)
}
// issueSession mints temporary credentials and the self-contained JWT that
// carries the whole session, shared by every assume-role entry point.
func (s *STSService) issueSession(roleArn, roleId, roleSessionName, sessionPolicy string,
func (s *STSService) issueSession(roleArn, roleSessionName, sessionPolicy string,
durationSeconds *int64, providerName, subject string) (*AssumeRoleResponse, error) {
sessionDuration := s.CalculateSessionDuration(durationSeconds)
sessionDuration := s.calculateSessionDuration(durationSeconds)
expiresAt := time.Now().Add(sessionDuration)
sessionId, err := GenerateSessionId()
@@ -804,7 +791,6 @@ func (s *STSService) issueSession(roleArn, roleId, roleSessionName, sessionPolic
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
WithSessionName(roleSessionName).
WithRoleInfo(roleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
WithRoleId(roleId).
WithIdentityProvider(providerName, subject, "").
WithMaxDuration(sessionDuration)
if sessionPolicy != "" {
@@ -905,9 +891,7 @@ func (s *STSService) AssumeRoleForPrincipal(ctx context.Context, request *Assume
return nil, fmt.Errorf("role assumption denied: %w", err)
}
// Iceberg credential vending resolves no role definition, so its sessions
// carry no role ID and stay bound by name.
return s.issueSession(request.RoleArn, "", request.RoleSessionName, sessionPolicy,
return s.issueSession(request.RoleArn, request.RoleSessionName, sessionPolicy,
request.DurationSeconds, request.ProviderName, request.Principal)
}
@@ -1136,11 +1120,11 @@ func (s *STSService) validateRoleAssumptionForCredentials(ctx context.Context, r
return nil
}
// CalculateSessionDuration returns the requested DurationSeconds, or the
// calculateSessionDuration returns the requested DurationSeconds, or the
// configured TokenDuration default, capped at MaxSessionLength. The source
// token's exp deliberately plays no part: per AWS semantics the session
// outlives the (already verified) web identity token.
func (s *STSService) CalculateSessionDuration(durationSeconds *int64) time.Duration {
func (s *STSService) calculateSessionDuration(durationSeconds *int64) time.Duration {
var duration time.Duration
if durationSeconds != nil {
duration = time.Duration(*durationSeconds) * time.Second
+2 -2
View File
@@ -30,8 +30,8 @@ func TestCalculateSessionDuration(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := svc.CalculateSessionDuration(tc.durationSeconds); got != tc.want {
t.Errorf("CalculateSessionDuration(%v) = %v, want %v", tc.durationSeconds, got, tc.want)
if got := svc.calculateSessionDuration(tc.durationSeconds); got != tc.want {
t.Errorf("calculateSessionDuration(%v) = %v, want %v", tc.durationSeconds, got, tc.want)
}
})
}
+3 -3
View File
@@ -211,9 +211,6 @@ func (fh *FileHandle) AddChunks(chunks []*filer_pb.FileChunk) {
}
func (fh *FileHandle) ReleaseHandle() {
fhActiveLock := fh.wfs.fhLockTable.AcquireLock("ReleaseHandle", fh.fh, util.ExclusiveLock)
defer fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
// Release distributed lock before cleaning up, so other mounts can
// proceed as soon as this handle is done flushing.
if fh.dlmLock != nil {
@@ -222,6 +219,9 @@ func (fh *FileHandle) ReleaseHandle() {
glog.V(1).Infof("DLM lock released for inode %d", fh.inode)
}
fhActiveLock := fh.wfs.fhLockTable.AcquireLock("ReleaseHandle", fh.fh, util.ExclusiveLock)
defer fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
if fh.entryChunkGroup != nil {
_ = fh.entryChunkGroup.Close()
}
+10 -30
View File
@@ -9,7 +9,6 @@ import (
"github.com/seaweedfs/go-fuse/v2/fuse"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -73,26 +72,10 @@ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, o
return code
}
// Acquire the DLM lock before the filer create: a lock failure after an
// eager create would return EAGAIN while the file stays persisted.
var dlmLock *cluster.LiveLock
if wfs.lockClient != nil {
owner := fmt.Sprintf("mount-%d", wfs.signature)
dlmLock = wfs.lockClient.NewBlockingLongLivedLock(
string(entryFullPath), owner, lock_manager.LiveLockTTL,
)
if dlmLock == nil {
return fuse.Status(syscall.EAGAIN)
}
}
inode, newEntry, code = wfs.createRegularFile(dirFullPath, name, in.Mode, in.Uid, in.Gid, 0, !wfs.option.EagerFilerCreate, true)
if code == fuse.Status(syscall.EEXIST) && in.Flags&syscall.O_EXCL == 0 {
// Race: another process created the file between our check and create.
// Reopen the winner's entry; AcquireHandle takes its own lock.
if dlmLock != nil {
dlmLock.Stop()
}
// Reopen the winner's entry.
newEntry, _, code = wfs.maybeLoadEntry(entryFullPath)
if code != fuse.OK {
return code
@@ -117,9 +100,6 @@ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, o
out.OpenFlags = 0
return fuse.OK
} else if code != fuse.OK {
if dlmLock != nil {
dlmLock.Stop()
}
return code
} else {
inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, false, inode, true)
@@ -142,15 +122,15 @@ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, o
// persisted the entry, so its handle starts clean.
fileHandle.dirtyMetadata = !wfs.option.EagerFilerCreate
// Create bypasses AcquireHandle, so attach the lock acquired above.
// A surviving handle may already hold one; ours is redundant then.
if dlmLock != nil {
if fileHandle.dlmLock == nil {
fileHandle.dlmLock = dlmLock
glog.V(1).Infof("DLM lock acquired for new file %s", entryFullPath)
} else {
dlmLock.Stop()
}
// Acquire DLM lock for new file creation (Create bypasses AcquireHandle
// so we must acquire the lock here). Always lock on Create since file
// creation is inherently a write operation.
if wfs.lockClient != nil && fileHandle.dlmLock == nil {
owner := fmt.Sprintf("mount-%d", wfs.signature)
fileHandle.dlmLock = wfs.lockClient.NewBlockingLongLivedLock(
string(entryFullPath), owner, lock_manager.LiveLockTTL,
)
glog.V(1).Infof("DLM lock acquired for new file %s", entryFullPath)
}
out.Fh = uint64(fileHandle.fh)
-7
View File
@@ -2,7 +2,6 @@ package mount
import (
"fmt"
"syscall"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
@@ -78,12 +77,6 @@ func (wfs *WFS) AcquireHandle(inode uint64, flags, uid, gid uint32) (fileHandle
fileHandle.dlmLock = wfs.lockClient.NewBlockingLongLivedLock(
string(path), owner, lock_manager.LiveLockTTL,
)
if fileHandle.dlmLock == nil {
// No lock server is reachable: proceeding would silently drop
// cross-mount write serialization, so fail the open instead.
wfs.fhMap.ReleaseByHandle(fileHandle.fh)
return nil, fuse.Status(syscall.EAGAIN)
}
glog.V(1).Infof("DLM lock acquired for %s", path)
}
return fileHandle, fuse.OK
+48 -126
View File
@@ -9,7 +9,6 @@ import (
"syscall"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -17,10 +16,10 @@ import (
)
// doRename tries the streaming mux first, falling back to unary on transport errors.
func (wfs *WFS) doRename(ctx context.Context, request *filer_pb.StreamRenameEntryRequest, oldPath, newPath util.FullPath, newPathLock **cluster.LiveLock) error {
func (wfs *WFS) doRename(ctx context.Context, request *filer_pb.StreamRenameEntryRequest, oldPath, newPath util.FullPath) error {
if wfs.streamMutate != nil && wfs.streamMutate.IsAvailable() {
err := wfs.streamMutate.Rename(ctx, request, func(resp *filer_pb.StreamRenameEntryResponse) error {
return wfs.handleRenameResponse(ctx, resp, newPath, newPathLock)
return wfs.handleRenameResponse(ctx, resp)
})
if err == nil || !errors.Is(err, ErrStreamTransport) {
return err // success or application error
@@ -40,7 +39,7 @@ func (wfs *WFS) doRename(ctx context.Context, request *filer_pb.StreamRenameEntr
}
return fmt.Errorf("dir Rename %s => %s receive: %v", oldPath, newPath, recvErr)
}
if err := wfs.handleRenameResponse(ctx, resp, newPath, newPathLock); err != nil {
if err := wfs.handleRenameResponse(ctx, resp); err != nil {
return err
}
}
@@ -241,102 +240,6 @@ func (wfs *WFS) Rename(cancel <-chan struct{}, in *fuse.RenameIn, oldName string
glog.V(4).Infof("dir Rename %s => %s", oldPath, newPath)
// Acquire DLM locks on both old and new paths to prevent another mount
// from opening either path for writing during the rename. Lock in
// sorted order to prevent deadlocks when two mounts rename in opposite
// directions (A→B vs B→A).
//
// Skip the old-path lock if this mount already holds it via an open
// file handle (otherwise we'd deadlock trying to re-acquire our own lock).
// Acquiring before the handle marks below keeps a lock failure from
// leaving source handles flagged for a rename that never happened.
var heldLocks []*cluster.LiveLock
var newPathLock *cluster.LiveLock
if wfs.lockClient != nil {
// A handle open on the target holds its lock; claiming it up front
// keeps the path locked through the rename (its close would otherwise
// release it mid-flight) without waiting on our own ownership.
var newPathLockFh *FileHandle
defer func() {
for _, l := range heldLocks {
l.Stop()
}
if newPathLock != nil {
// Never adopted by a migrated handle — return it to the
// handle it came from if that is still open.
if newPathLockFh != nil {
if cur, ok := wfs.fhMap.FindFileHandle(newPathLockFh.inode); ok && cur == newPathLockFh {
lk := wfs.fhLockTable.AcquireLock("renameDLM", cur.fh, util.ExclusiveLock)
if cur.dlmLock == nil {
cur.dlmLock = newPathLock
newPathLock = nil
}
wfs.fhLockTable.ReleaseLock(cur.fh, lk)
}
}
if newPathLock != nil {
newPathLock.Stop()
}
}
}()
owner := fmt.Sprintf("mount-%d", wfs.signature)
// Check if the source file handle already holds a DLM lock on oldPath
oldPathAlreadyLocked := false
sourceInode, sourceMapped := wfs.inodeToPath.GetInode(oldPath)
if !sourceMapped && oldEntry != nil && oldEntry.Attributes != nil {
sourceInode = oldEntry.Attributes.Inode
}
if sourceInode != 0 {
if fh, ok := wfs.fhMap.FindFileHandle(sourceInode); ok {
lk := wfs.fhLockTable.AcquireLock("renameDLM", fh.fh, util.ExclusiveLock)
oldPathAlreadyLocked = fh.dlmLock != nil
wfs.fhLockTable.ReleaseLock(fh.fh, lk)
}
}
targetInode, targetMapped := wfs.inodeToPath.GetInode(newPath)
if !targetMapped && newEntry != nil && newEntry.Attributes != nil {
targetInode = newEntry.Attributes.Inode
}
if targetInode != 0 && targetInode != sourceInode {
if targetFh, ok := wfs.fhMap.FindFileHandle(targetInode); ok {
targetFhLock := wfs.fhLockTable.AcquireLock("renameDLM", targetFh.fh, util.ExclusiveLock)
if targetFh.dlmLock != nil {
newPathLock = targetFh.dlmLock
targetFh.dlmLock = nil
newPathLockFh = targetFh
}
wfs.fhLockTable.ReleaseLock(targetFh.fh, targetFhLock)
}
}
// Determine which paths need new DLM locks
pathsToLock := []string{}
if newPathLock == nil {
pathsToLock = append(pathsToLock, string(newPath))
}
if !oldPathAlreadyLocked && string(oldPath) != string(newPath) {
pathsToLock = append(pathsToLock, string(oldPath))
}
// Sort for consistent lock ordering
if len(pathsToLock) == 2 && pathsToLock[0] > pathsToLock[1] {
pathsToLock[0], pathsToLock[1] = pathsToLock[1], pathsToLock[0]
}
for _, p := range pathsToLock {
dlmLock := wfs.lockClient.NewBlockingLongLivedLock(p, owner, lock_manager.LiveLockTTL)
if dlmLock == nil {
return fuse.Status(syscall.EAGAIN)
}
if p == string(newPath) {
newPathLock = dlmLock
} else {
heldLocks = append(heldLocks, dlmLock)
}
}
glog.V(1).Infof("DLM locks acquired for rename %s => %s (oldPathAlreadyLocked=%v)", oldPath, newPath, oldPathAlreadyLocked)
}
// Ensure the source file's metadata exists on the filer before renaming.
// Two cases can leave the entry only in the local cache:
// 1. deferFilerCreate=true — file handle still open, dirtyMetadata set.
@@ -397,6 +300,41 @@ func (wfs *WFS) Rename(cancel <-chan struct{}, in *fuse.RenameIn, oldName string
}
}
// Acquire DLM locks on both old and new paths to prevent another mount
// from opening either path for writing during the rename. Lock in
// sorted order to prevent deadlocks when two mounts rename in opposite
// directions (A→B vs B→A).
//
// Skip the old-path lock if this mount already holds it via an open
// file handle (otherwise we'd deadlock trying to re-acquire our own lock).
if wfs.lockClient != nil {
owner := fmt.Sprintf("mount-%d", wfs.signature)
// Check if the source file handle already holds a DLM lock on oldPath
oldPathAlreadyLocked := false
if sourceInode, found := wfs.inodeToPath.GetInode(oldPath); found {
if fh, ok := wfs.fhMap.FindFileHandle(sourceInode); ok && fh.dlmLock != nil {
oldPathAlreadyLocked = true
}
}
// Determine which paths need new DLM locks
pathsToLock := []string{string(newPath)}
if !oldPathAlreadyLocked {
pathsToLock = append(pathsToLock, string(oldPath))
}
// Sort for consistent lock ordering
if len(pathsToLock) == 2 && pathsToLock[0] > pathsToLock[1] {
pathsToLock[0], pathsToLock[1] = pathsToLock[1], pathsToLock[0]
}
for _, p := range pathsToLock {
dlmLock := wfs.lockClient.NewBlockingLongLivedLock(p, owner, lock_manager.LiveLockTTL)
defer dlmLock.Stop()
}
glog.V(1).Infof("DLM locks acquired for rename %s => %s (oldPathAlreadyLocked=%v)", oldPath, newPath, oldPathAlreadyLocked)
}
// update remote filer
request := &filer_pb.StreamRenameEntryRequest{
OldDirectory: string(oldDir),
@@ -407,7 +345,7 @@ func (wfs *WFS) Rename(cancel <-chan struct{}, in *fuse.RenameIn, oldName string
}
ctx := context.Background()
err := wfs.doRename(ctx, request, oldPath, newPath, &newPathLock)
err := wfs.doRename(ctx, request, oldPath, newPath)
if err != nil {
glog.V(0).Infof("Rename %s => %s: %v", oldPath, newPath, err)
// Map error strings to FUSE status codes. String matching is used
@@ -439,7 +377,7 @@ func (wfs *WFS) Rename(cancel <-chan struct{}, in *fuse.RenameIn, oldName string
}
func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamRenameEntryResponse, renameNewPath util.FullPath, renameNewPathLock **cluster.LiveLock) error {
func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamRenameEntryResponse) error {
// comes from filer StreamRenameEntry, can only be create or delete entry
glog.V(4).Infof("dir Rename %+v", resp.EventNotification)
@@ -473,32 +411,16 @@ func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamR
// Migrate the DLM lock from old path to new path so the
// lock key matches the current file location. Hold the
// fhLockTable to prevent ReleaseHandle from concurrently
// stopping the lock during migration. Acquire before
// releasing: a failed migration keeps the old lock rather
// than leaving the handle unlocked.
if wfs.lockClient != nil && oldPath != newPath {
// stopping the lock during migration.
if wfs.lockClient != nil {
fhActiveLock := wfs.fhLockTable.AcquireLock("renameDLM", fh.fh, util.ExclusiveLock)
if fh.dlmLock != nil {
var newLock *cluster.LiveLock
if newPath == renameNewPath && renameNewPathLock != nil && *renameNewPathLock != nil {
// The rename already holds a lock on the target;
// adopt it — re-acquiring our own lock would block
// until this handle is released.
newLock = *renameNewPathLock
*renameNewPathLock = nil
} else {
owner := fmt.Sprintf("mount-%d", wfs.signature)
newLock = wfs.lockClient.NewBlockingLongLivedLock(
string(newPath), owner, lock_manager.LiveLockTTL,
)
}
if newLock != nil {
fh.dlmLock.Stop()
fh.dlmLock = newLock
glog.V(1).Infof("DLM lock migrated from %s to %s", oldPath, newPath)
} else {
glog.Warningf("DLM lock migration to %s failed; keeping lock on %s", newPath, oldPath)
}
owner := fmt.Sprintf("mount-%d", wfs.signature)
fh.dlmLock.Stop()
fh.dlmLock = wfs.lockClient.NewBlockingLongLivedLock(
string(newPath), owner, lock_manager.LiveLockTTL,
)
glog.V(1).Infof("DLM lock migrated from %s to %s", oldPath, newPath)
}
wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
}

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