Compare commits

..
Author SHA1 Message Date
Chris LuGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
fafd8493fb Update .github/workflows/kms-tests.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-26 12:00:38 -07:00
Chris Lu b270f1698d refactor: combine SSE-S3 and SSE-KMS metadata serialization into else-if
SSE-S3 and SSE-KMS bucket default encryption are mutually exclusive, so
use a single if/else-if block instead of two independent if blocks.
2026-03-26 11:55:49 -07:00
Chris Lu 105257e9b5 ci: add KMS integration tests to GitHub Actions
Add a kms-tests.yml workflow that runs on changes to KMS/SSE code with
two jobs:

1. KMS provider tests: starts OpenBao via Docker, runs Go integration
   tests in test/kms/ against a real KMS backend
2. S3 KMS e2e tests: starts OpenBao + weed mini built from source, runs
   test_s3_kms.sh which covers bucket-default SSE-KMS upload/download
   (the exact scenario from #8776)

Supporting changes:
- test/kms/Makefile: add CI targets (test-provider-ci, test-s3-kms-ci)
  that manage OpenBao via plain Docker and run weed from source
- test/kms/s3-config-openbao-template.json: S3 config template with
  OpenBao KMS provider for weed mini
2026-03-26 11:37:13 -07:00
Chris Lu 4bd8e5dd59 fix: serialize SSE-KMS metadata when bucket default encryption applies KMS
When a bucket has default SSE-KMS encryption enabled and a file is uploaded
without explicit SSE headers, the encryption was applied correctly but the
SSE-KMS metadata (x-seaweedfs-sse-kms-key) was not serialized. This caused
downloads to fail with "empty SSE-KMS metadata" because the entry's Extended
map stored an empty byte slice.

The existing code already handled this for SSE-S3 bucket defaults
(SerializeSSES3Metadata) but was missing the equivalent call to
SerializeSSEKMSMetadata for the KMS path.

Fixes seaweedfs/seaweedfs#8776
2026-03-26 11:28:41 -07:00
Chris LuandGitHub aa12b51cbf test: restore coverage removed in PR #8360 (#8779)
* test: restore maintenance mode coverage in TestVolumeMarkReadonlyWritableErrorPaths

PR #8360 removed the maintenance mode assertions because the refactored
check ordering (volume lookup before maintenance check) caused the
original test to hit "not found" instead of "maintenance mode" — the
test used a non-existent volume ID.

Restore coverage by allocating a real volume, then verifying:
- existing volume in maintenance mode returns "maintenance mode"
- non-existent volume in maintenance mode still returns "not found"
  (validating the new check ordering)

* test: add coverage for ScrubVolume MarkBrokenVolumesReadonly flag

PR #8360 added the mark_broken_volumes_readonly field to ScrubVolumeRequest
but no tests exercised the new logic paths. Add three integration tests:

- HealthyVolume: flag is a no-op when scrub finds no broken volumes
- CorruptVolume: corrupted .idx triggers broken detection; without the
  flag the volume stays writable, with the flag it becomes read-only
- MaintenanceMode: makeVolumeReadonly fails under maintenance and
  ScrubVolume propagates the error via errors.Join

* refactor: extract CorruptIndexFile and EnableMaintenanceMode test helpers

Move duplicated idx corruption and maintenance mode setup into
framework.CorruptIndexFile() and framework.EnableMaintenanceMode()
helpers. Use defer for file close in the corruption helper.
2026-03-26 10:52:37 -07:00
Lisandro PinandGitHub e5cf2d2a19 Give the ScrubVolume() RPC an option to flag found broken volumes as read-only. (#8360)
* Give the `ScrubVolume()` RPC an option to flag found broken volumes as read-only.

Also exposes this option in the shell `volume.scrub` command.

* Remove redundant test in `TestVolumeMarkReadonlyWritableErrorPaths`.

417051bb slightly rearranges the logic for `VolumeMarkReadonly()` and `VolumeMarkWritable()`,
so calling them for invalid volume IDs will actually yield that error, instead of checking
maintnenance mode first.
2026-03-26 10:20:57 -07:00
6cf34f2376 Add -filerExcludePathPattern flag and fix nil panic in -filerExcludeFileName (#8756)
* Fix filerExcludeFileName to support directory names and path components

The original implementation only matched excludeFileName against
message.NewEntry.Name, which caused two issues:

1. Nil pointer panic on delete events (NewEntry is nil)
2. Files inside excluded directories were still backed up because
   the parent directory name was not checked

This patch:
- Checks all path components in resp.Directory against the regexp
- Adds nil guard for message.NewEntry before accessing .Name
- Also checks message.OldEntry.Name for rename/delete events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add -filerExcludePathPattern flag and fix nil panic in filerExcludeFileName

Separate concerns between two exclude mechanisms:
- filerExcludeFileName: matches entry name only (leaf node)
- filerExcludePathPattern (NEW): matches any path component via regexp,
  so files inside matched directories are also excluded

Also fixes nil pointer panic when filerExcludeFileName encounters
delete events where NewEntry is nil.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor exclude logic: per-side exclusion for rename events, reduce duplication

- Extract isEntryExcluded() to compute exclusion per old/new side,
  so rename events crossing an exclude boundary are handled as
  delete + create instead of being entirely skipped
- Extract compileExcludePattern() to deduplicate regexp compilation
- Replace strings.Split with allocation-free pathContainsMatch()
- Check message.NewParentPath (not just resp.Directory) for new side

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Move regexp compilation out of retry loop to fail fast on config errors

compileExcludePattern for -filerExcludeFileName and -filerExcludePathPattern
are configuration-time validations that will never succeed on retry.
Move them to runFilerBackup before the reconnect loop and use glog.Fatalf
on failure, so invalid patterns are caught immediately at startup instead
of being retried every 1.7 seconds indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add wildcard matching helpers for path and filename exclusion

* Replace regexp exclude patterns with wildcard-based flags, deprecate -filerExcludeFileName

Add -filerExcludeFileNames and -filerExcludePathPatterns flags that accept
comma-separated wildcard patterns (*, ?) using the existing wildcard library.
Mark -filerExcludeFileName as deprecated but keep its regexp behavior.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-03-26 10:04:06 -07:00
Chris LuandGitHub ccc662b90b shell: add s3.bucket.access command for anonymous access policy (#8774)
* shell: add s3.bucket.access command for anonymous access policy (#7738)

Add a new weed shell command to view or change the anonymous access
policy of an S3 bucket without external tools.

Usage:
  s3.bucket.access -name <bucket> -access read,list
  s3.bucket.access -name <bucket> -access none

Supported permissions: read, write, list. The command writes a standard
bucket policy with Principal "*" and warns if no anonymous IAM identity
exists.

* shell: fix anonymous identity hint in s3.bucket.access warning

The anonymous identity doesn't need IAM actions — the bucket policy
controls what anonymous users can do.

* shell: only warn about anonymous identity when write access is set

Read and list operations use AuthWithPublicRead which evaluates bucket
policies directly without requiring the anonymous identity. Only write
operations go through the normal auth flow that needs it.

* shell: rewrite s3.bucket.access to use IAM actions instead of bucket policies

Replace the bucket policy approach with direct IAM identity actions,
matching the s3.configure pattern. The user is auto-created if it does
not exist.

Usage:
  s3.bucket.access -name <bucket> -user anonymous -access Read,List
  s3.bucket.access -name <bucket> -user anonymous -access none
  s3.bucket.access -name <bucket> -user anonymous

Actions are stored as "Action:bucket" on the identity, same as
s3.configure -actions=Read -buckets=my-bucket.

* shell: return flag parse errors instead of swallowing them

* shell: normalize action names case-insensitively in s3.bucket.access

Accept actions in any case (read, READ, Read) and normalize to canonical
form (Read, Write, List, etc.) before storing. This matches the
case-insensitive handling of "none" and avoids confusing rejections.
2026-03-25 23:09:53 -07:00
Chris LuandGitHub 67a551fd62 admin UI: add anonymous user creation checkbox (#8773)
Add an "Anonymous" checkbox next to the username field in the Create User
modal. When checked, the username is set to "anonymous" and the credential
generation checkbox is disabled since anonymous users do not need keys.

The checkbox is only shown when no anonymous user exists yet. The
manage-access-keys button in the users table is hidden for the anonymous
user.
2026-03-25 21:24:10 -07:00
Chris LuandGitHub 94bfa2b340 mount: stream all filer mutations over single ordered gRPC stream (#8770)
* filer: add StreamMutateEntry bidi streaming RPC

Add a bidirectional streaming RPC that carries all filer mutation
types (create, update, delete, rename) over a single ordered stream.
This eliminates per-request connection overhead for pipelined
operations and guarantees mutation ordering within a stream.

The server handler delegates each request to the existing unary
handlers (CreateEntry, UpdateEntry, DeleteEntry) and uses a proxy
stream adapter for rename operations to reuse StreamRenameEntry logic.

The is_last field signals completion for multi-response operations
(rename sends multiple events per request; create/update/delete
always send exactly one response with is_last=true).

* mount: add streaming mutation multiplexer (streamMutateMux)

Implement a client-side multiplexer that routes all filer mutation RPCs
(create, update, delete, rename) over a single bidirectional gRPC
stream. Multiple goroutines submit requests through a send channel;
a dedicated sendLoop serializes them on the stream; a recvLoop
dispatches responses to waiting callers via per-request channels.

Key features:
- Lazy stream opening on first use
- Automatic reconnection on stream failure
- Permanent fallback to unary RPCs if filer returns Unimplemented
- Monotonic request_id for response correlation
- Multi-response support for rename operations (is_last signaling)

The mux is initialized on WFS and closed during unmount cleanup.
No call sites use it yet — wiring comes in subsequent commits.

* mount: route CreateEntry and UpdateEntry through streaming mux

Wire all CreateEntry call sites to use wfs.streamCreateEntry() which
routes through the StreamMutateEntry stream when available, falling
back to unary RPCs otherwise. Also wire Link's UpdateEntry calls
through wfs.streamUpdateEntry().

Updated call sites:
- flushMetadataToFiler (file flush after write)
- Mkdir (directory creation)
- Symlink (symbolic link creation)
- createRegularFile non-deferred path (Mknod)
- flushFileMetadata (periodic metadata flush)
- Link (hard link: update source + create link + rollback)

* mount: route UpdateEntry and DeleteEntry through streaming mux

Wire remaining mutation call sites through the streaming mux:

- saveEntry (Setattr/chmod/chown/utimes) → streamUpdateEntry
- Unlink → streamDeleteEntry (replaces RemoveWithResponse)
- Rmdir → streamDeleteEntry (replaces RemoveWithResponse)

All filer mutations except Rename now go through StreamMutateEntry
when the filer supports it, with automatic unary RPC fallback.

* mount: route Rename through streaming mux

Wire Rename to use streamMutate.Rename() when available, with
fallback to the existing StreamRenameEntry unary stream.

The streaming mux sends rename as a StreamRenameEntryRequest oneof
variant. The server processes it through the existing rename logic
and sends multiple StreamRenameEntryResponse events (one per moved
entry), with is_last=true on the final response.

All filer mutations now go through a single ordered stream.

* mount: fix stream mux connection ownership

WithGrpcClient(streamingMode=true) closes the gRPC connection when
the callback returns, destroying the stream. Own the connection
directly via pb.GrpcDial so it stays alive for the stream's lifetime.
Close it explicitly in recvLoop on stream failure and in Close on
shutdown.

* mount: fix rename failure for deferred-create files

Three fixes for rename operations over the streaming mux:

1. lookupEntry: fall back to local metadata store when filer returns
   "not found" for entries in uncached directories. Files created with
   deferFilerCreate=true exist only in the local leveldb store until
   flushed; lookupEntry skipped the local store when the parent
   directory had never been readdir'd, causing rename to fail with
   ENOENT.

2. Rename: wait for pending async flushes and force synchronous flush
   of dirty metadata before sending rename to the filer. Covers the
   writebackCache case where close() defers the flush to a background
   worker that may not complete before rename fires.

3. StreamMutateEntry: propagate rename errors from server to client.
   Add error/errno fields to StreamMutateEntryResponse so the mount
   can map filer errors to correct FUSE status codes instead of
   silently returning OK. Also fix the existing Rename error handler
   which could return fuse.OK on unrecognized errors.

* mount: fix streaming mux error handling, sendLoop lifecycle, and fallback

Address PR review comments:

1. Server: populate top-level Error/Errno on StreamMutateEntryResponse for
   create/update/delete errors, not just rename. Previously update errors
   were silently dropped and create/delete errors were only in nested
   response fields that the client didn't check.

2. Client: check nested error fields in CreateEntry (ErrorCode, Error)
   and DeleteEntry (Error) responses, matching CreateEntryWithResponse
   behavior.

3. Fix sendLoop lifecycle: give each stream generation a stopSend channel.
   recvLoop closes it on error to stop the paired sendLoop. Previously a
   reconnect left the old sendLoop draining sendCh, breaking ordering.

4. Transparent fallback: stream helpers and doRename fall back to unary
   RPCs on transport errors (ErrStreamTransport), including the first
   Unimplemented from ensureStream. Previously the first call failed
   instead of degrading.

5. Filer rotation in openStream: try all filer addresses on dial failure,
   matching WithFilerClient behavior. Stop early on Unimplemented.

6. Pass metadata-bearing context to StreamMutateEntry RPC call so
   sw-client-id header is actually sent.

7. Gate lookupEntry local-cache fallback on open dirty handle or pending
   async flush to avoid resurrecting deleted/renamed entries.

8. Remove dead code in flushFileMetadata (err=nil followed by if err!=nil).

9. Use string matching for rename error-to-errno mapping in the mount to
   stay portable across Linux/macOS (numeric errno values differ).

* mount: make failAllPending idempotent with delete-before-close

Change failAllPending to collect pending entries into a local slice
(deleting from the sync.Map first) before closing channels. This
prevents double-close panics if called concurrently. Also remove the
unused err parameter.

* mount: add stream generation tracking and teardownStream

Introduce a generation counter on streamMutateMux that increments each
time a new stream is created. Requests carry the generation they were
enqueued for so sendLoop can reject stale requests after reconnect.

Add teardownStream(gen) which is idempotent (only acts when gen matches
current generation and stream is non-nil). Both sendLoop and recvLoop
call it on error, replacing the inline cleanup in recvLoop. sendLoop now
actively triggers teardown on send errors instead of silently exiting.

ensureStream waits for the prior generation's recvDone before creating a
new stream, ensuring all old pending waiters are failed before reconnect.
recvLoop now takes the stream, generation, and recvDone channel as
parameters to avoid accessing shared fields without the lock.

* mount: harden Close to prevent races with teardownStream

Nil out stream, cancel, and grpcConn under the lock so that any
concurrent teardownStream call from recvLoop/sendLoop becomes a no-op.
Call failAllPending before closing sendCh to unblock waiters promptly.
Guard recvDone with a nil check for the case where Close is called
before any stream was ever opened.

* mount: make errCh receive ctx-aware in doUnary and Rename

Replace the blocking <-sendReq.errCh with a select that also observes
ctx.Done(). If sendLoop exits via stopSend without consuming a buffered
request, the caller now returns ctx.Err() instead of blocking forever.
The buffered errCh (capacity 1) ensures late acknowledgements from
sendLoop don't block the sender.

* mount: fix sendLoop/Close race and recvLoop/teardown pending channel race

Three related fixes:

1. Stop closing sendCh in Close(). Closing the shared producer channel
   races with callers who passed ensureStream() but haven't sent yet,
   causing send-on-closed-channel panics. sendCh is now left open;
   ensureStream checks m.closed to reject new callers.

2. Drain buffered sendCh items on shutdown. sendLoop defers drainSendCh()
   on exit so buffered requests get an ErrStreamTransport on their errCh
   instead of blocking forever. Close() drains again for any stragglers
   enqueued between sendLoop's drain and the final shutdown.

3. Move failAllPending from teardownStream into recvLoop's defer.
   teardownStream (called from sendLoop on send error) was closing
   pending response channels while recvLoop could be between
   pending.Load and the channel send — a send-on-closed-channel panic.
   recvLoop is now the sole closer of pending channels, eliminating
   the race. Close() waits on recvDone (with cancel() to guarantee
   Recv unblocks) so pending cleanup always completes.

* filer/mount: add debug logging for hardlink lifecycle

Add V(0) logging at every point where a HardLinkId is created, stored,
read, or deleted to trace orphaned hardlink references. Logging covers:

- gRPC server: CreateEntry/UpdateEntry when request carries HardLinkId
- FilerStoreWrapper: InsertEntry/UpdateEntry when entry has HardLinkId
- handleUpdateToHardLinks: entry path, HardLinkId, counter, chunk count
- setHardLink: KvPut with blob size
- maybeReadHardLink: V(1) on read attempt and successful decode
- DeleteHardLink: counter decrement/deletion events
- Mount Link(): when NewHardLinkId is generated and link is created

This helps diagnose how a git pack .rev file ended up with a
HardLinkId during a clone (no hard links should be involved).

* test: add git clone/pull integration test for FUSE mount

Shell script that exercises git operations on a SeaweedFS mount:
1. Creates a bare repo on the mount
2. Clones locally, makes 3 commits, pushes to mount
3. Clones from mount bare repo into an on-mount working dir
4. Verifies clone integrity (files, content, commit hashes)
5. Pushes 2 more commits with renames and deletes
6. Checks out an older revision on the mount clone
7. Returns to branch and pulls with real changes
8. Verifies file content, renames, deletes after pull
9. Checks git log integrity and clean status

27 assertions covering file existence, content, commit hashes,
file counts, renames, deletes, and git status. Run against any
existing mount: bash test-git-on-mount.sh /path/to/mount

* test: add git clone/pull FUSE integration test to CI suite

Add TestGitOperations to the existing fuse_integration test framework.
The test exercises git's full file operation surface on the mount:

  1. Creates a bare repo on the mount (acts as remote)
  2. Clones locally, makes 3 commits (files, bulk data, renames), pushes
  3. Clones from mount bare repo into an on-mount working dir
  4. Verifies clone integrity (content, commit hash, file count)
  5. Pushes 2 more commits with new files, renames, and deletes
  6. Checks out an older revision on the mount clone
  7. Returns to branch and pulls with real fast-forward changes
  8. Verifies post-pull state: content, renames, deletes, file counts
  9. Checks git log integrity (5 commits) and clean status

Runs automatically in the existing fuse-integration.yml CI workflow.

* mount: fix permission check with uid/gid mapping

The permission checks in createRegularFile() and Access() compared
the caller's local uid/gid against the entry's filer-side uid/gid
without applying the uid/gid mapper. With -map.uid 501:0, a directory
created as uid 0 on the filer would not match the local caller uid
501, causing hasAccess() to fall through to "other" permission bits
and reject write access (0755 → other has r-x, no w).

Fix: map entry uid/gid from filer-space to local-space before the
hasAccess() call so both sides are in the same namespace.

This fixes rsync -a failing with "Permission denied" on mkstempat
when using uid/gid mapping.

* mount: fix Mkdir/Symlink returning filer-side uid/gid to kernel

Mkdir and Symlink used `defer wfs.mapPbIdFromFilerToLocal(entry)` to
restore local uid/gid, but `outputPbEntry` writes the kernel response
before the function returns — so the kernel received filer-side
uid/gid (e.g., 0:0). macFUSE then caches these and rejects subsequent
child operations (mkdir, create) because the caller uid (501) doesn't
match the directory owner (0), and "other" bits (0755 → r-x) lack
write permission.

Fix: replace the defer with an explicit call to mapPbIdFromFilerToLocal
before outputPbEntry, so the kernel gets local uid/gid.

Also add nil guards for UidGidMapper in Access and createRegularFile
to prevent panics in tests that don't configure a mapper.

This fixes rsync -a "Permission denied" on mkpathat for nested
directories when using uid/gid mapping.

* mount: fix Link outputting filer-side uid/gid to kernel, add nil guards

Link had the same defer-before-outputPbEntry bug as Mkdir and Symlink:
the kernel received filer-side uid/gid because the defer hadn't run
yet when outputPbEntry wrote the response.

Also add nil guards for UidGidMapper in Access and createRegularFile
so tests without a mapper don't panic.

Audit of all outputPbEntry/outputFilerEntry call sites:
- Mkdir: fixed in prior commit (explicit map before output)
- Symlink: fixed in prior commit (explicit map before output)
- Link: fixed here (explicit map before output)
- Create (existing file): entry from maybeLoadEntry (already mapped)
- Create (deferred): entry has local uid/gid (never mapped to filer)
- Create (non-deferred): createRegularFile defer runs before return
- Mknod: createRegularFile defer runs before return
- Lookup: entry from lookupEntry (already mapped)
- GetAttr: entry from maybeReadEntry/maybeLoadEntry (already mapped)
- readdir: entry from cache (mapIdFromFilerToLocal) or filer (mapped)
- saveEntry: no kernel output
- flushMetadataToFiler: no kernel output
- flushFileMetadata: no kernel output

* test: fix git test for same-filesystem FUSE clone

When both the bare repo and working clone live on the same FUSE mount,
git's local transport uses hardlinks and cross-repo stat calls that
fail on FUSE. Fix:

- Use --no-local on clone to disable local transport optimizations
- Use reset --hard instead of checkout to stay on branch
- Use fetch + reset --hard origin/<branch> instead of git pull to
  avoid local transport stat failures during fetch

* adjust logging

* test: use plain git clone/pull to exercise real FUSE behavior

Remove --no-local and fetch+reset workarounds. The test should use
the same git commands users run (clone, reset --hard, pull) so it
reveals real FUSE issues rather than hiding them.

* test: enable V(1) logging for filer/mount and collect logs on failure

- Run filer and mount with -v=1 so hardlink lifecycle logs (V(0):
  create/delete/insert, V(1): read attempts) are captured
- On test failure, automatically dump last 16KB of all process logs
  (master, volume, filer, mount) to test output
- Copy process logs to /tmp/seaweedfs-fuse-logs/ for CI artifact upload
- Update CI workflow to upload SeaweedFS process logs alongside test output

* mount: clone entry for filer flush to prevent uid/gid race

flushMetadataToFiler and flushFileMetadata used entry.GetEntry() which
returns the file handle's live proto entry pointer, then mutated it
in-place via mapPbIdFromLocalToFiler. During the gRPC call window, a
concurrent Lookup (which takes entryLock.RLock but NOT fhLockTable)
could observe filer-side uid/gid (e.g., 0:0) on the file handle entry
and return it to the kernel. The kernel caches these attributes, so
subsequent opens by the local user (uid 501) fail with EACCES.

Fix: proto.Clone the entry before mapping uid/gid for the filer request.
The file handle's live entry is never mutated, so concurrent Lookup
always sees local uid/gid.

This fixes the intermittent "Permission denied" on .git/FETCH_HEAD
after the first git pull on a mount with uid/gid mapping.

* mount: add debug logging for stale lock file investigation

Add V(0) logging to trace the HEAD.lock recreation issue:
- Create: log when O_EXCL fails (file already exists) with uid/gid/mode
- completeAsyncFlush: log resolved path, saved path, dirtyMetadata,
  isDeleted at entry to trace whether async flush fires after rename
- flushMetadataToFiler: log the dir/name/fullpath being flushed

This will show whether the async flush is recreating the lock file
after git renames HEAD.lock → HEAD.

* mount: prevent async flush from recreating renamed .lock files

When git renames HEAD.lock → HEAD, the async flush from the prior
close() can run AFTER the rename and re-insert HEAD.lock into the
meta cache via its CreateEntryRequest response event. The next git
pull then sees HEAD.lock and fails with "File exists".

Fix: add isRenamed flag on FileHandle, set by Rename before waiting
for the pending async flush. The async flush checks this flag and
skips the metadata flush for renamed files (same pattern as isDeleted
for unlinked files). The data pages still flush normally.

The Rename handler flushes deferred metadata synchronously (Case 1)
before setting isRenamed, ensuring the entry exists on the filer for
the rename to proceed. For already-released handles (Case 2), the
entry was created by a prior flush.

* mount: also mark renamed inodes via entry.Attributes.Inode fallback

When GetInode fails (Forget already removed the inode mapping), the
Rename handler couldn't find the pending async flush to set isRenamed.
The async flush then recreated the .lock file on the filer.

Fix: fall back to oldEntry.Attributes.Inode to find the pending async
flush when the inode-to-path mapping is gone. Also extract
MarkInodeRenamed into a method on FileHandleToInode for clarity.

* mount: skip async metadata flush when saved path no longer maps to inode

The isRenamed flag approach failed for refs/remotes/origin/HEAD.lock
because neither GetInode nor oldEntry.Attributes.Inode could find the
inode (Forget already evicted the mapping, and the entry's stored
inode was 0).

Add a direct check in completeAsyncFlush: before flushing metadata,
verify that the saved path still maps to this inode in the inode-to-path
table. If the path was renamed or removed (inode mismatch or not found),
skip the metadata flush to avoid recreating a stale entry.

This catches all rename cases regardless of whether the Rename handler
could set the isRenamed flag.

* mount: wait for pending async flush in Unlink before filer delete

Unlink was deleting the filer entry first, then marking the draining
async-flush handle as deleted. The async flush worker could race between
these two operations and recreate the just-unlinked entry on the filer.
This caused git's .lock files (e.g. refs/remotes/origin/HEAD.lock) to
persist after git pull, breaking subsequent git operations.

Move the isDeleted marking and add waitForPendingAsyncFlush() before
the filer delete so any in-flight flush completes first. Even if the
worker raced past the isDeleted check, the wait ensures it finishes
before the filer delete cleans up any recreated entry.

* mount: reduce async flush and metadata flush log verbosity

Raise completeAsyncFlush entry log, saved-path-mismatch skip log, and
flushMetadataToFiler entry log from V(0) to V(3)/V(4). These fire for
every file close with writebackCache and are too noisy for normal use.

* filer: reduce hardlink debug log verbosity from V(0) to V(4)

HardLinkId logs in filerstore_wrapper, filerstore_hardlink, and
filer_grpc_server fire on every hardlinked file operation (git pack
files use hardlinks extensively) and produce excessive noise.

* mount/filer: reduce noisy V(0) logs for link, rmdir, and empty folder check

- weedfs_link.go: hardlink creation logs V(0) → V(4)
- weedfs_dir_mkrm.go: non-empty folder rmdir error V(0) → V(1)
- empty_folder_cleaner.go: "not empty" check log V(0) → V(4)

* filer: handle missing hardlink KV as expected, not error

A "kv: not found" on hardlink read is normal when the link blob was
already cleaned up but a stale entry still references it. Log at V(1)
for not-found; keep Error level for actual KV failures.

* test: add waitForDir before git pull in FUSE git operations test

After git reset --hard, the FUSE mount's metadata cache may need a
moment to settle on slow CI. The git pull subprocess (unpack-objects)
could fail to stat the working directory. Poll for up to 5s.

* Update git_operations_test.go

* wait

* test: simplify FUSE test framework to use weed mini

Replace the 4-process setup (master + volume + filer + mount) with
2 processes: "weed mini" (all-in-one) + "weed mount". This simplifies
startup, reduces port allocation, and is faster on CI.

* test: fix mini flag -admin → -admin.ui
2026-03-25 20:06:34 -07:00
Chris LuandGitHub 29bdbb3c48 filer.sync: replace O(n) conflict check with O(depth) index lookups (#8772)
* filer.sync: replace O(n) conflict check with O(depth) index lookups

The MetadataProcessor.conflictsWith() scanned all active jobs linearly
for every new event dispatch. At high concurrency (256-1024), this O(n)
scan under the activeJobsLock became a bottleneck that throttled the
event dispatch pipeline, negating the benefit of higher -concurrency
values.

Replace the linear scan with three index maps:
- activeFilePaths: O(1) exact file path lookup
- activeDirPaths: O(1) directory path lookup per ancestor
- descendantCount: O(1) check for active jobs under a directory

Conflict check is now O(depth) where depth is the path depth (typically
3-6 levels), constant regardless of active job count. Benchmark confirms
~81ns per check whether there are 32 or 1024 active jobs.

Also replace the O(n) watermark scan with minActiveTs tracking so
non-oldest job completions are O(1).

Ref: #8771

* filer.sync: replace O(n) watermark rescan with min-heap lazy deletion

Address review feedback:
- Replace minActiveTs O(n) rescan with a tsMinHeap using lazy deletion.
  Each TsNs is pushed once and popped once, giving O(log n) amortized
  watermark tracking regardless of completion order.
- Fix benchmark to consume conflictsWith result via package-level sink
  variable to prevent compiler elision.

The watermark advancement semantics (conservative, sets to completing
job's TsNs) are unchanged from the original code. This is intentionally
safe for idempotent replay on restart.
2026-03-25 15:43:25 -07:00
79f4a4579f feat(k8s): added possibility to specify service.type for multiple ser… (#8372)
* feat(k8s): added possibility to specify service.type for multiple services in helm chart

* fix(k8s): removed headless (clusterIP: None) from services

* fix(k8s): keep master and filer services headless for StatefulSet compatibility

Master and filer services must remain headless (clusterIP: None) because
their StatefulSets reference them via serviceName for stable pod DNS.
Revert the service.type change for these two services and remove their
unused service config from values.yaml. S3 and SFTP remain configurable.

---------

Co-authored-by: Andreas Røste <andreas2101@gmail.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-03-25 11:30:14 -07:00
Chris LuandGitHub e47054a7e7 mount: improve small file write performance (#8769)
* mount: defer file creation gRPC to flush time for faster small file writes

When creating a file via FUSE Create(), skip the synchronous gRPC
CreateEntry call to the filer. Instead, allocate the inode and build
the entry locally, deferring the filer create to the Flush/Release path
where flushMetadataToFiler already sends a CreateEntry with chunk data.

This eliminates one synchronous gRPC round-trip per file during creation.
For workloads with many small files (e.g. 30K files), this reduces the
per-file overhead from ~2 gRPC calls to ~1.

Mknod retains synchronous filer creation since it has no file handle
and thus no flush path.

* mount: use bounded worker pool for async flush operations

Replace unbounded goroutine spawning in writebackCache async flush
with a fixed-size worker pool backed by a channel. When many files
are closed rapidly (e.g., cp -r of 30K files), the previous approach
spawned one goroutine per file, leading to resource contention on
gRPC/HTTP connections and high goroutine overhead.

The worker pool size matches ConcurrentWriters (default 128), which
provides good parallelism while bounding resource usage. Work items
are queued into a buffered channel and processed by persistent worker
goroutines.

* mount: fix deferred create cache visibility and async flush race

Three fixes for the deferred create and async flush changes:

1. Insert a local placeholder entry into the metadata cache during
   deferred file creation so that maybeLoadEntry() can find the file
   for duplicate-create checks, stat, and readdir. Uses InsertEntry
   directly (not applyLocalMetadataEvent) to avoid triggering the
   directory hot-threshold eviction that would wipe the entry.

2. Fix race in ReleaseHandle where asyncFlushWg.Add(1) and the
   channel send happened after pendingAsyncFlushMu was unlocked.
   A concurrent WaitForAsyncFlush could observe a zero counter,
   close the channel, and cause a send-on-closed panic. Move Add(1)
   before the unlock; keep the send after unlock to avoid deadlock
   with workers that acquire the same mutex during cleanup.

3. Update TestCreateCreatesAndOpensFile to flush the file handle
   before verifying the CreateEntry gRPC call, since file creation
   is now deferred to flush time.
2026-03-24 20:31:53 -07:00
28fe92065a S3: reject part uploads after AbortMultipartUpload (#8768)
* S3: reject part uploads after AbortMultipartUpload

PutObjectPartHandler did not verify that the multipart upload session
still exists before accepting parts. After AbortMultipartUpload deleted
the upload directory, the ErrNotFound from getEntry was silently ignored
(treated as "may be non-SSE upload"), allowing parts to be stored as
orphaned files.

Now return ErrNoSuchUpload when the upload directory is not found,
matching AWS S3 behavior.

Fixes #8766

* S3: check upload existence unconditionally in PutObjectPartHandler

Move the getEntry call out of the SSE-type conditional so the upload
existence check runs for all part uploads, including SSE-C. Previously
the SSE-C path skipped the check entirely, allowing parts to be uploaded
after abort when SSE-C headers were present.

Also flattens the nested SSE branching by one level now that getEntry
is called once upfront.

* S3: address PR review feedback for PutObjectPartHandler

- Log at error level when getEntry fails with an unexpected error,
  since we return ErrInternalError to the client
- Distinguish base IV decode errors from length validation failures
  with separate, clearer error messages

---------

Co-authored-by: Copilot <copilot@github.com>
2026-03-24 18:11:51 -07:00
Chris LuandGitHub 0b3867dca3 filer: add structured error codes to CreateEntryResponse (#8767)
* filer: add FilerError enum and error_code field to CreateEntryResponse

Add a machine-readable error code alongside the existing string error
field. This follows the precedent set by PublishMessageResponse in the
MQ broker proto. The string field is kept for human readability and
backward compatibility.

Defined codes: OK, ENTRY_NAME_TOO_LONG, PARENT_IS_FILE,
EXISTING_IS_DIRECTORY, EXISTING_IS_FILE, ENTRY_ALREADY_EXISTS.

* filer: add sentinel errors and error code mapping in filer_pb

Define sentinel errors (ErrEntryNameTooLong, ErrParentIsFile, etc.) in
the filer_pb package so both the filer and consumers can reference them
without circular imports.

Add FilerErrorToSentinel() to map proto error codes to sentinels, and
update CreateEntryWithResponse() to check error_code first, falling back
to the string-based path for backward compatibility with old servers.

* filer: return wrapped sentinel errors and set proto error codes

Replace fmt.Errorf string errors in filer.CreateEntry, UpdateEntry, and
ensureParentDirectoryEntry with wrapped filer_pb sentinel errors (using
%w). This preserves errors.Is() traversal on the server side.

In the gRPC CreateEntry handler, map sentinel errors to the
corresponding FilerError proto codes using errors.Is(), setting both
resp.Error (string, for backward compat) and resp.ErrorCode (enum).

* S3: use errors.Is() with filer sentinels instead of string matching

Replace fragile string-based error matching in filerErrorToS3Error and
other S3 API consumers with errors.Is() checks against filer_pb sentinel
errors. This works because the updated CreateEntryWithResponse helper
reconstructs sentinel errors from the proto FilerError code.

Update iceberg stage_create and metadata_files to check resp.ErrorCode
instead of parsing resp.Error strings. Update SSE-S3 to use errors.Is()
for the already-exists check.

String matching is retained only for non-filer errors (gRPC transport
errors, checksum validation) that don't go through CreateEntryResponse.

* filer: remove backward-compat string fallbacks for error codes

Clients and servers are always deployed together, so there is no need
for backward-compatibility fallback paths that parse resp.Error strings
when resp.ErrorCode is unset. Simplify all consumers to rely solely on
the structured error code.

* iceberg: ensure unknown non-OK error codes are not silently ignored

When FilerErrorToSentinel returns nil for an unrecognized error code,
return an error including the code and message rather than falling
through to return nil.

* filer: fix redundant error message and restore error wrapping in helper

Use request path instead of resp.Error in the sentinel error format
string to avoid duplicating the sentinel message (e.g. "entry already
exists: entry already exists"). Restore %w wrapping with errors.New()
in the fallback paths so callers can use errors.Is()/errors.As().

* filer: promote file to directory on path conflict instead of erroring

S3 allows both "foo/bar" (object) and "foo/bar/xyzzy" (another object)
to coexist because S3 has a flat key space. When ensureParentDirectoryEntry
finds a parent path that is a file instead of a directory, promote it to
a directory by setting ModeDir while preserving the original content and
chunks. Use Store.UpdateEntry directly to bypass the Filer.UpdateEntry
type-change guard.

This fixes the S3 compatibility test failures where creating overlapping
keys (e.g. "foo/bar" then "foo/bar/xyzzy") returned ExistingObjectIsFile.
2026-03-24 17:08:22 -07:00
Chris LuandGitHub 152884eff2 S3: add s3: prefix to x-amz-* condition keys for AWS compatibility (#8765)
AWS S3 policy conditions reference request headers with the s3: namespace
prefix (e.g., s3:x-amz-server-side-encryption). The extraction code was
storing these headers without the prefix, so bucket policy conditions
using the standard AWS key names would never match.
2026-03-24 14:04:42 -07:00
Chris LuandGitHub 2877febd73 S3: fix silent PutObject failure and enforce 1024-byte key limit (#8764)
* S3: add KeyTooLongError error code

Add ErrKeyTooLongError (HTTP 400, code "KeyTooLongError") to match the
standard AWS S3 error for object keys that exceed length limits.

* S3: fix silent PutObject failure when entry name exceeds max_file_name_length

putToFiler called client.CreateEntry() directly and discarded the gRPC
response. The filer embeds application errors like "entry name too long"
in resp.Error (not as gRPC transport errors), so the error was silently
swallowed and clients received HTTP 200 with an ETag for objects that
were never stored.

Switch to the filer_pb.CreateEntry() helper which properly checks
resp.Error, and map "entry name too long" to KeyTooLongError (HTTP 400).

To avoid fragile string parsing across the gRPC boundary, define shared
error message constants in weed/util/constants and use them in both the
filer (producing errors) and S3 API (matching errors). Switch
filerErrorToS3Error to use strings.Contains/HasSuffix with these
constants so matches work regardless of any wrapper prefix. Apply
filerErrorToS3Error to the mkdir path for directory markers.

Fixes #8759

* S3: enforce 1024-byte maximum object key length

AWS S3 limits object keys to 1024 bytes. Add early validation on write
paths (PutObject, CopyObject, CreateMultipartUpload) to reject keys
exceeding the limit with the standard KeyTooLongError (HTTP 400).

The key length check runs before bucket auto-creation to prevent
overlong keys from triggering unnecessary side effects.

Also use filerErrorToS3Error for CopyObject's mkFile error paths so
name-too-long errors from the filer return KeyTooLongError instead of
InternalError.

Ref #8758

* S3: add handler-level tests for key length validation and error mapping

Add tests for filerErrorToS3Error mapping "entry name too long" to
KeyTooLongError, including a regression test for the CreateEntry-prefixed
"existing ... is a directory" form. Add handler-level integration tests
that exercise PutObjectHandler, CopyObjectHandler, and
NewMultipartUploadHandler via httptest, verifying HTTP 400 and
KeyTooLongError XML response for overlong keys and acceptance of keys at
the 1024-byte limit.
2026-03-24 13:35:28 -07:00
Chris LuandGitHub 7f3f61ea28 fix: resolve Kafka gateway response deadlocks causing Sarama client hangs (#8762)
* fix: resolve Kafka gateway response deadlocks causing Sarama client hangs

Fix three bugs in the Kafka protocol handler that caused sequential
clients (notably Sarama) to hang during E2E tests:

1. Race condition in correlation queue ordering: the correlation ID was
   added to the response ordering queue AFTER sending the request to
   the processing channel. A fast processor (e.g. ApiVersions) could
   finish and send its response before the ID was in the queue, causing
   the response writer to miss it — permanently deadlocking the
   connection. Now the ID is added BEFORE the channel send, with error
   response injection on send failure.

2. Silent error response drops: when processRequestSync returned an
   error, the response writer logged it but never sent anything back to
   the client. The client would block forever waiting for bytes that
   never arrived. Now sends a Kafka UNKNOWN_SERVER_ERROR response.

3. Produce V0/V1 missing timeout_ms parsing: the handler skipped the
   4-byte timeout field, reading it as topicsCount instead. This caused
   incorrect parsing of the entire produce request for V0/V1 clients.

* fix: API-versioned error responses, unsupported-version queue fix, V0V1 header alignment

1. errors.go — BuildAPIErrorResponse: emits a minimal-but-valid error
   body whose layout matches the schema the client expects for each API
   key and version (throttle_time position, array fields, etc.).  The
   old 2-byte generic body corrupted the protocol stream for APIs whose
   response begins with throttle_time_ms or an array.

2. handler.go — unsupported-version path: the correlationID was never
   added to correlationQueue before sending to responseChan, so the
   response writer could never match it and the client hung.  Now
   appends the ID under correlationQueueMu before the send.

3. produce.go — handleProduceV0V1: requestBody is already post-header
   (HandleConn strips client_id).  The handler was erroneously parsing
   acks bytes as a client_id length, misaligning all subsequent field
   reads.  Removed the client_id parsing; offset now starts at 0 with
   acks(2) + timeout_ms(4) + topicsCount(4), matching handleProduceV2Plus.

* fix: free pooled message buffer per-iteration instead of deferring

The read loop allocated messageBuf via mem.Allocate and deferred
mem.Free.  Since the defer only runs when HandleConn returns, pool
buffers accumulated for the entire connection lifetime — one per
request.  Worse, the deferred frees ran in LIFO order before
wg.Wait(), so processing goroutines could read from already-freed
pool buffers.

Now: read into a pooled buffer, immediately copy to Go-managed
memory, and return the pool buffer.  messageBuf is a regular slice
safe for async goroutine access with no defer accumulation.

* fix: cancel context before wg.Wait and on worker response-send timeout

Two related issues:

1. Cleanup defer ordering deadlock: defers run LIFO — the cleanup defer
   (close channels, wg.Wait) ran before the cancel() defer.  The
   response writer is in the WaitGroup and exits only on ctx.Done() or
   responseChan close, but both signals came after wg.Wait().  Deadlock
   on every normal connection close (EOF, read error, queue-full).
   Fix: call cancel() at the start of the cleanup defer, before
   wg.Wait().

2. Worker 5s response-send timeout: when the timeout fired, the
   response was silently dropped but the correlationID remained in the
   ordered queue.  The response writer could never advance past it,
   stalling all subsequent responses permanently.
   Fix: call cancel() to tear down the connection — if we cannot
   deliver a response in 5s the connection is irrecoverable.

* chore: remove empty no-op ListOffsets conditional

The `if apiKey == 2 {}` block had no body — leftover debug code.
ListOffsets routing is handled by isDataPlaneAPI (returns false,
sending it to the control channel). No behavior change.
2026-03-24 13:17:25 -07:00
Chris LuandGitHub 6c35a3724a weed/mount: simplify metadata flush retry returns (#8763) 2026-03-24 12:24:56 -07:00
cca1555cc7 mount: implement create for rsync temp files (#8749)
* mount: implement create for rsync temp files

* mount: move access implementation out of unsupported

* mount: tighten access checks

* mount: log access group lookup failures

* mount: reset dirty pages on truncate

* mount: tighten create and root access handling

* mount: handle existing creates before quota checks

* mount: restrict access fallback when group lookup fails

When lookupSupplementaryGroupIDs returns an error, the previous code
fell through to checking only the "other" permission bits, which could
overgrant access.  Require both group and other permission classes to
satisfy the mask so access is never broader than intended.

* mount: guard against nil entry in Create existing-file path

maybeLoadEntry can return OK with a nil entry or nil Attributes in
edge cases.  Check before dereferencing to prevent a panic.

* mount: reopen existing file on create race without O_EXCL

When createRegularFile returns EEXIST because another process won the
race, and O_EXCL is not set, reload the winner's entry and open it
instead of propagating the error to the caller.

* mount: check parent directory permission in createRegularFile

Verify the caller has write+search (W_OK|X_OK) permission on the
parent directory before creating a file.  This applies to both
Create and Mknod.  Update test fixture mount mode to 0o777 so the
existing tests pass with the new check.

* mount: enforce file permission bits in AcquireHandle

Map the open flags (O_RDONLY/O_WRONLY/O_RDWR) to an access mask and
call hasAccess before handing out a file handle.  This makes
AcquireHandle the single source of truth for mode-based access
control across Open, Create-existing, and Create-new paths.

---------

Co-authored-by: Copilot <copilot@github.com>
2026-03-24 11:43:41 -07:00
Chris LuandGitHub 805625d06e Add FUSE integration tests for POSIX file locking (#8752)
* Add FUSE integration tests for POSIX file locking

Test flock() and fcntl() advisory locks over the FUSE mount:
- Exclusive and shared flock with conflict detection
- flock upgrade (shared to exclusive) and release on close
- fcntl F_SETLK write lock conflicts and shared read locks
- fcntl F_GETLK conflict reporting on overlapping byte ranges
- Non-overlapping byte-range locks held independently
- F_SETLKW blocking until conflicting lock is released
- Lock release on file descriptor close
- Concurrent lock contention with multiple workers

* Fix review feedback in POSIX lock integration tests

- Assert specific EAGAIN error on fcntl lock conflicts instead of generic Error
- Use O_APPEND in concurrent contention test so workers append rather than overwrite
- Verify exact line count (numWorkers * writesPerWorker) after concurrent test
- Check unlock error in F_SETLKW blocking test goroutine

* Refactor fcntl tests to use subprocesses for inter-process semantics

POSIX fcntl locks use the process's files_struct as lock owner, so all
fds in the same process share the same owner and never conflict. This
caused the fcntl tests to silently pass without exercising lock conflicts.

Changes:
- Add TestFcntlLockHelper subprocess entry point with hold/try/getlk actions
- Add lockHolder with channel-based coordination (no scanner race)
- Rewrite all fcntl tests to run contenders in separate subprocesses
- Fix F_UNLCK int16 cast in GetLk assertion for type-safe comparison
- Fix concurrent test: use non-blocking flock with retry to avoid
  exhausting go-fuse server reader goroutines (blocking FUSE SETLKW
  can starve unlock request processing, causing deadlock)

flock tests remain same-process since flock uses per-struct-file owners.

* Fix misleading comment and error handling in lock test subprocess

- Fix comment: tryLockInSubprocess tests a subprocess, not the test process
- Distinguish EAGAIN/EACCES from unexpected errors in subprocess try mode
  so real failures aren't silently masked as lock conflicts

* Fix CI race in FcntlReleaseOnClose and increase flock retry budget

- FcntlReleaseOnClose: retry lock acquisition after subprocess exits
  since the FUSE server may not process Release immediately
- ConcurrentLockContention: increase retry limit from 500 to 3000
  (5s → 30s budget) to handle CI load

* separating flock and fcntl in the in-memory lock table and cleaning them up through the right release path: PID for POSIX locks, lock owner for flock

* ReleasePosixOwner

* weed/mount: flush before releasing posix close owner

* weed/mount: keep woken lock waiters from losing inode state

* test/fuse: make blocking fcntl helper state explicit

* test/fuse: assert flock contention never overlaps

* test/fuse: stabilize concurrent lock contention check

* test/fuse: make concurrent contention writes deterministic

* weed/mount: retry synchronous metadata flushes
2026-03-24 11:43:25 -07:00
Lars LehtonenandGitHub 9cc26d09e8 chore:(weed/worker/tasks/erasure_coding): Prune Unused and Untested Functions (#8761)
* chore(weed/worker/tasks/erasure_coding): prune unused findVolumeReplicas()

* chore(weed/worker/tasks/erasure_coding): prune unused isDiskSuitableForEC()

* chore(weed/worker/tasks/erasure_coding): prune unused selectBestECDestinations()

* chore(weed/worker/tasks/erasure_coding): prune unused candidatesToDiskInfos()
2026-03-24 10:10:28 -07:00
Chris LuandGitHub 3d872e86f8 Implement POSIX file locking for FUSE mount (#8750)
* Add POSIX byte-range lock table for FUSE mount

Implement PosixLockTable with per-inode range lock tracking supporting:
- Shared (F_RDLCK) and exclusive (F_WRLCK) byte-range locks
- Conflict detection across different lock owners
- Lock coalescing for adjacent/overlapping same-owner same-type locks
- Lock splitting on partial-range unlock
- Blocking waiter support for SetLkw with cancellation
- Owner-based cleanup for Release

* Wire POSIX lock handlers into FUSE mount

Implement GetLk, SetLk, SetLkw on WFS delegating to PosixLockTable.
Add posixLocks field to WFS and initialize in constructor.
Clean up locks on Release via ReleaseOwner using ReleaseIn.LockOwner.
Remove ENOSYS stubs from weedfs_unsupported.go.

* Enable POSIX and flock lock capabilities in FUSE mount

Set EnableLocks: true in mount options to advertise
CAP_POSIX_LOCKS and CAP_FLOCK_LOCKS during FUSE INIT.

* Avoid thundering herd in lock waiter wake-up

Replace broadcast-all wakeWaiters with selective wakeEligibleWaiters
that checks each waiter's requested lock against remaining held locks.
Only waiters whose request no longer conflicts are woken; others stay
queued. Store the requested lockRange in each lockWaiter to enable this.

* Fix uint64 overflow in adjacency check for lock coalescing

Guard h.End+1 and lk.End+1 with < ^uint64(0) checks so that
End == math.MaxUint64 (EOF) does not wrap to 0 and falsely merge
non-adjacent locks.

* Add test for non-adjacent ranges with gap not being coalesced
2026-03-23 22:18:51 -07:00
Chris Lu 2844c70ecf fix tests 2026-03-23 19:36:14 -07:00
Chris LuandGitHub e5f72077ee fix: resolve CORS cache race condition causing stale 404 responses (#8748)
The metadata subscription handler (updateBucketConfigCacheFromEntry) was
making a separate RPC call via loadCORSFromBucketContent to load CORS
configuration. This created a race window where a slow CreateBucket
subscription event could re-cache stale data after PutBucketCors had
already cleared the cache, causing subsequent GetBucketCors to return
404 NoSuchCORSConfiguration.

Parse CORS directly from the subscription entry's Content field instead
of making a separate RPC. Also fix getBucketConfig to parse CORS from
the already-fetched entry, eliminating a redundant RPC call.

Fix TestCORSCaching to use require.NoError to prevent nil pointer
dereference panics when GetBucketCors fails.
2026-03-23 19:33:20 -07:00
c31e6b4684 Use filer-side copy for mounted whole-file copy_file_range (#8747)
* Optimize mounted whole-file copy_file_range

* Address mounted copy review feedback

* Harden mounted copy fast path

---------

Co-authored-by: Copilot <copilot@github.com>
2026-03-23 18:35:15 -07:00
Chris LuandGitHub 6bf654c25c fix: keep metadata subscriptions progressing (#8730) (#8746)
* fix: keep metadata subscriptions progressing (#8730)

* test: cancel slow metadata writers with parent context

* filer: ignore missing persisted log chunks
2026-03-23 15:26:54 -07:00
Chris LuandGitHub d5ee35c8df Fix S3 delete for non-empty directory markers (#8740)
* Fix S3 delete for non-empty directory markers

* Address review feedback on directory marker deletes

* Stabilize FUSE concurrent directory operations
2026-03-23 13:35:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b3b7033fe1 build(deps): bump github.com/klauspost/compress from 1.18.4 to 1.18.5 (#8739)
Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.18.4 to 1.18.5.
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.4...v1.18.5)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 11:59:53 -07:00
Mmx233andGitHub ecadeddcbe fix: extend ignore404Error to match 404 Not Found string from S3 sink… (#8741)
* fix: extend ignore404Error to match 404 Not Found string from S3 sink errors

* test: add unit tests for isIgnorable404 error matching

* improve: pre-compute ignorable 404 string and simplify isIgnorable404

* test: replace init() with TestMain for global HTTP client setup
2026-03-23 11:59:34 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
156e1a6e64 build(deps): bump gocloud.dev/pubsub/rabbitpubsub from 0.44.0 to 0.45.0 (#8737)
Bumps [gocloud.dev/pubsub/rabbitpubsub](https://github.com/google/go-cloud) from 0.44.0 to 0.45.0.
- [Release notes](https://github.com/google/go-cloud/releases)
- [Commits](https://github.com/google/go-cloud/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: gocloud.dev/pubsub/rabbitpubsub
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 11:55:16 -07:00
Copilot 2b6271469b Merge branch 'master' of https://github.com/seaweedfs/seaweedfs 2026-03-23 11:22:45 -07:00
Copilot 963ec4c6e6 remove claude from github ci 2026-03-23 11:22:19 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fb442a57d7 build(deps): bump actions/checkout from 4 to 6 (#8738)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:50:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a080fbb495 build(deps): bump github.com/go-ldap/ldap/v3 from 3.4.12 to 3.4.13 (#8736)
Bumps [github.com/go-ldap/ldap/v3](https://github.com/go-ldap/ldap) from 3.4.12 to 3.4.13.
- [Release notes](https://github.com/go-ldap/ldap/releases)
- [Commits](https://github.com/go-ldap/ldap/compare/v3.4.12...v3.4.13)

---
updated-dependencies:
- dependency-name: github.com/go-ldap/ldap/v3
  dependency-version: 3.4.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:50:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d6479b6d4e build(deps): bump github.com/klauspost/reedsolomon from 1.13.0 to 1.13.3 (#8735)
Bumps [github.com/klauspost/reedsolomon](https://github.com/klauspost/reedsolomon) from 1.13.0 to 1.13.3.
- [Release notes](https://github.com/klauspost/reedsolomon/releases)
- [Commits](https://github.com/klauspost/reedsolomon/compare/v1.13.0...v1.13.3)

---
updated-dependencies:
- dependency-name: github.com/klauspost/reedsolomon
  dependency-version: 1.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:49:49 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
17800f63a7 build(deps): bump golang.org/x/crypto from 0.48.0 to 0.49.0 (#8734)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:49:34 -07:00
123 changed files with 9850 additions and 1425 deletions
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Set BUILD_TIME env
run: echo BUILD_TIME=$(date -u +%Y%m%d-%H%M) >> ${GITHUB_ENV}
@@ -87,7 +87,7 @@ jobs:
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Set BUILD_TIME env
run: echo BUILD_TIME=$(date -u +%Y%m%d-%H%M) >> ${GITHUB_ENV}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Go Release Binaries Normal Volume Size
uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.22
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Go Release Binaries Normal Volume Size
uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.22
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Go Release Binaries Normal Volume Size
uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.22
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Go Release Binaries Normal Volume Size
uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.22
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Go Release Binaries Normal Volume Size
uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.22
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Go Release Binaries Normal Volume Size
uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.22
with:
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
-
name: Docker meta
id: docker_meta
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v2
- name: Prepare Docker tag
id: tag
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: 'Dependency Review'
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48
+1
View File
@@ -77,4 +77,5 @@ jobs:
name: fuse-integration-test-results
path: |
/tmp/fuse-test-output.log
/tmp/seaweedfs-fuse-logs/
retention-days: 7
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
fetch-depth: 0
+141
View File
@@ -0,0 +1,141 @@
name: "KMS Tests"
on:
pull_request:
paths:
- 'weed/kms/**'
- 'weed/s3api/s3_sse_*.go'
- 'weed/s3api/s3api_object_handlers.go'
- 'weed/s3api/s3api_object_handlers_put.go'
- 'test/kms/**'
- '.github/workflows/kms-tests.yml'
push:
branches: [ master, main ]
paths:
- 'weed/kms/**'
- 'weed/s3api/s3_sse_*.go'
- 'weed/s3api/s3api_object_handlers.go'
- 'weed/s3api/s3api_object_handlers_put.go'
- 'test/kms/**'
concurrency:
group: ${{ github.head_ref || github.ref }}-kms-tests
cancel-in-progress: true
permissions:
contents: read
defaults:
run:
working-directory: weed
jobs:
kms-provider-tests:
name: KMS Provider Integration Tests
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
id: go
- name: Install SeaweedFS
run: |
go install -buildvcs=false
- name: Run KMS provider integration tests
timeout-minutes: 15
working-directory: test/kms
run: |
set -x
echo "=== System Information ==="
uname -a
free -h
docker --version
make test-provider-ci
- name: Show OpenBao logs on failure
if: failure()
run: |
echo "=== OpenBao Container Logs ==="
docker logs openbao-ci 2>&1 | tail -50 || echo "No OpenBao container found"
echo "=== Setup Logs ==="
cat /tmp/openbao-ci-setup.log 2>/dev/null || echo "No setup log found"
- name: Cleanup
if: always()
working-directory: test/kms
run: |
make stop-openbao-ci || true
s3-kms-e2e-tests:
name: S3 KMS End-to-End Tests
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
id: go
- name: Install SeaweedFS
run: |
go install -buildvcs=false
- name: Run S3 KMS end-to-end tests
timeout-minutes: 20
working-directory: test/kms
run: |
set -x
echo "=== System Information ==="
uname -a
free -h
docker --version
aws --version
make test-s3-kms-ci
- name: Show logs on failure
if: failure()
working-directory: test/kms
run: |
echo "=== OpenBao Container Logs ==="
docker logs openbao-ci 2>&1 | tail -50 || echo "No OpenBao container found"
echo "=== SeaweedFS Server Logs ==="
tail -100 /tmp/seaweedfs-kms-mini.log 2>/dev/null || echo "No server log found"
echo "=== Setup Logs ==="
cat /tmp/openbao-ci-setup.log 2>/dev/null || echo "No OpenBao setup log"
cat /tmp/weed-kms-ci-setup.log 2>/dev/null || echo "No weed setup log"
echo "=== Process Information ==="
ps aux | grep -E "(weed|test)" || true
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: s3-kms-e2e-logs
path: |
/tmp/seaweedfs-kms-mini.log
/tmp/openbao-ci-setup.log
/tmp/weed-kms-ci-setup.log
retention-days: 3
- name: Cleanup
if: always()
working-directory: test/kms
run: |
make stop-seaweedfs-ci || true
make stop-openbao-ci || true
make clean-ci || true
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
#
# Integration test: git clone & pull on a SeaweedFS FUSE mount.
#
# Verifies that the mount correctly supports git's file operations by:
# 1. Creating a bare repo on the mount (acts as a remote)
# 2. Cloning it, making commits, and pushing back to the mount
# 3. Cloning from the mount into a working directory also on the mount
# 4. Pushing additional commits to the bare repo
# 5. Checking out an older revision in the on-mount clone
# 6. Running git pull to fast-forward with real changes
# 7. Verifying file content integrity at each step
#
# Usage:
# bash test-git-on-mount.sh /path/to/mount/point
#
# The mount must already be running. All test artifacts are created under
# <mount>/git-test-<pid> and cleaned up on exit (unless TEST_KEEP=1).
#
set -euo pipefail
MOUNT_DIR="${1:?Usage: $0 <mount-dir>}"
TEST_DIR="$MOUNT_DIR/git-test-$$"
LOCAL_DIR=$(mktemp -d)
PASS=0
FAIL=0
cleanup() {
if [[ "${TEST_KEEP:-}" == "1" ]]; then
echo "TEST_KEEP=1 — leaving artifacts:"
echo " mount: $TEST_DIR"
echo " local: $LOCAL_DIR"
else
rm -rf "$TEST_DIR" 2>/dev/null || true
rm -rf "$LOCAL_DIR" 2>/dev/null || true
fi
}
trap cleanup EXIT
# --- helpers ---------------------------------------------------------------
pass() { PASS=$((PASS + 1)); echo " PASS: $1"; }
fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1"; }
assert_file_contains() {
local file=$1 expected=$2 label=$3
if [[ -f "$file" ]] && grep -qF "$expected" "$file" 2>/dev/null; then
pass "$label"
else
fail "$label (expected '$expected' in $file)"
fi
}
assert_file_exists() {
local file=$1 label=$2
if [[ -f "$file" ]]; then
pass "$label"
else
fail "$label ($file not found)"
fi
}
assert_file_not_exists() {
local file=$1 label=$2
if [[ ! -f "$file" ]]; then
pass "$label"
else
fail "$label ($file should not exist)"
fi
}
assert_eq() {
local actual=$1 expected=$2 label=$3
if [[ "$actual" == "$expected" ]]; then
pass "$label"
else
fail "$label (expected '$expected', got '$actual')"
fi
}
# --- setup -----------------------------------------------------------------
echo "========================================"
echo " Git-on-mount integration test"
echo "========================================"
echo "Mount: $MOUNT_DIR"
echo "Test: $TEST_DIR"
echo "Local: $LOCAL_DIR"
echo ""
if ! mountpoint -q "$MOUNT_DIR" 2>/dev/null && [[ ! -d "$MOUNT_DIR" ]]; then
echo "ERROR: $MOUNT_DIR is not a valid directory"
exit 1
fi
mkdir -p "$TEST_DIR"
# --- Phase 1: Create bare repo on mount -----------------------------------
echo "--- Phase 1: Create bare repo on mount ---"
BARE_REPO="$TEST_DIR/repo.git"
git init --bare "$BARE_REPO" >/dev/null 2>&1
pass "bare repo created on mount"
# --- Phase 2: Clone locally, make initial commits, push -------------------
echo "--- Phase 2: Clone locally, make initial commits, push ---"
LOCAL_CLONE="$LOCAL_DIR/clone1"
git clone "$BARE_REPO" "$LOCAL_CLONE" >/dev/null 2>&1
cd "$LOCAL_CLONE"
git config user.email "test@seaweedfs.test"
git config user.name "SeaweedFS Test"
# Commit 1: initial files
echo "hello world" > README.md
mkdir -p src
echo 'package main; import "fmt"; func main() { fmt.Println("v1") }' > src/main.go
git add -A && git commit -m "initial commit" >/dev/null 2>&1
COMMIT1=$(git rev-parse HEAD)
# Commit 2: add more files
mkdir -p data
for i in $(seq 1 20); do
printf "file-%03d: %s\n" "$i" "$(head -c 64 /dev/urandom | base64)" > "data/file-$(printf '%03d' $i).txt"
done
git add -A && git commit -m "add data files" >/dev/null 2>&1
COMMIT2=$(git rev-parse HEAD)
# Commit 3: modify and add
echo 'package main; import "fmt"; func main() { fmt.Println("v2") }' > src/main.go
echo "# Updated readme" >> README.md
mkdir -p docs
echo "documentation content" > docs/guide.md
git add -A && git commit -m "update src and add docs" >/dev/null 2>&1
COMMIT3=$(git rev-parse HEAD)
git push origin master >/dev/null 2>&1 || git push origin main >/dev/null 2>&1
BRANCH=$(git rev-parse --abbrev-ref HEAD)
pass "3 commits pushed to mount bare repo (branch=$BRANCH)"
# --- Phase 3: Clone from mount bare repo to mount working dir -------------
echo "--- Phase 3: Clone from mount bare repo to on-mount working dir ---"
MOUNT_CLONE="$TEST_DIR/working"
git clone "$BARE_REPO" "$MOUNT_CLONE" >/dev/null 2>&1
# Verify clone integrity
assert_file_exists "$MOUNT_CLONE/README.md" "README.md exists after clone"
assert_file_contains "$MOUNT_CLONE/README.md" "# Updated readme" "README.md has latest content"
assert_file_contains "$MOUNT_CLONE/src/main.go" 'v2' "src/main.go has v2"
assert_file_exists "$MOUNT_CLONE/docs/guide.md" "docs/guide.md exists"
assert_file_exists "$MOUNT_CLONE/data/file-001.txt" "data files exist"
assert_file_exists "$MOUNT_CLONE/data/file-020.txt" "data/file-020.txt exists"
CLONE_HEAD=$(cd "$MOUNT_CLONE" && git rev-parse HEAD)
assert_eq "$CLONE_HEAD" "$COMMIT3" "on-mount clone HEAD matches commit 3"
# Count files
FILE_COUNT=$(find "$MOUNT_CLONE/data" -name '*.txt' | wc -l | tr -d ' ')
assert_eq "$FILE_COUNT" "20" "data/ has 20 files"
# --- Phase 4: Push more commits from local clone --------------------------
echo "--- Phase 4: Push more commits from local clone ---"
cd "$LOCAL_CLONE"
# Commit 4: larger changes
for i in $(seq 21 50); do
printf "file-%03d: %s\n" "$i" "$(head -c 128 /dev/urandom | base64)" > "data/file-$(printf '%03d' $i).txt"
done
echo 'package main; import "fmt"; func main() { fmt.Println("v3") }' > src/main.go
git add -A && git commit -m "expand data and update to v3" >/dev/null 2>&1
COMMIT4=$(git rev-parse HEAD)
# Commit 5: rename and delete
git mv docs/guide.md docs/manual.md
git rm data/file-001.txt >/dev/null 2>&1
git commit -m "rename guide, remove file-001" >/dev/null 2>&1
COMMIT5=$(git rev-parse HEAD)
git push origin "$BRANCH" >/dev/null 2>&1
pass "2 more commits pushed (5 total)"
# --- Phase 5: Checkout older revision in on-mount clone -------------------
echo "--- Phase 5: Checkout older revision in on-mount clone ---"
cd "$MOUNT_CLONE"
git checkout "$COMMIT2" >/dev/null 2>&1
# Verify we're at commit 2 state
DETACHED_HEAD=$(git rev-parse HEAD)
assert_eq "$DETACHED_HEAD" "$COMMIT2" "on-mount clone at commit 2 (detached)"
assert_file_not_exists "$MOUNT_CLONE/docs/guide.md" "docs/guide.md not in commit 2"
assert_file_contains "$MOUNT_CLONE/src/main.go" 'v1' "src/main.go has v1 at commit 2"
# --- Phase 6: Return to branch and pull -----------------------------------
echo "--- Phase 6: Return to branch and pull with real changes ---"
cd "$MOUNT_CLONE"
git checkout "$BRANCH" >/dev/null 2>&1
# At this point the on-mount clone is at commit 3, remote is at commit 5
OLD_HEAD=$(git rev-parse HEAD)
assert_eq "$OLD_HEAD" "$COMMIT3" "on-mount clone at commit 3 before pull"
git pull >/dev/null 2>&1
NEW_HEAD=$(git rev-parse HEAD)
assert_eq "$NEW_HEAD" "$COMMIT5" "HEAD matches commit 5 after pull"
# Verify commit 5 state
assert_file_contains "$MOUNT_CLONE/src/main.go" 'v3' "src/main.go has v3 after pull"
assert_file_exists "$MOUNT_CLONE/docs/manual.md" "docs/manual.md exists (renamed)"
assert_file_not_exists "$MOUNT_CLONE/docs/guide.md" "docs/guide.md gone (renamed)"
assert_file_not_exists "$MOUNT_CLONE/data/file-001.txt" "data/file-001.txt removed"
assert_file_exists "$MOUNT_CLONE/data/file-050.txt" "data/file-050.txt exists"
FINAL_COUNT=$(find "$MOUNT_CLONE/data" -name '*.txt' | wc -l | tr -d ' ')
assert_eq "$FINAL_COUNT" "49" "data/ has 49 files (50 added, 1 removed)"
# --- Phase 7: Verify git log integrity -----------------------------------
echo "--- Phase 7: Verify git log integrity ---"
cd "$MOUNT_CLONE"
GIT_LOG=$(git log --format=%s)
LOG_COUNT=$(echo "$GIT_LOG" | wc -l | tr -d ' ')
assert_eq "$LOG_COUNT" "5" "git log shows 5 commits"
# Verify commit messages
echo "$GIT_LOG" | grep -qF "initial commit" && pass "commit 1 message in log" || fail "commit 1 message missing"
echo "$GIT_LOG" | grep -qF "expand data" && pass "commit 4 message in log" || fail "commit 4 message missing"
echo "$GIT_LOG" | grep -qF "rename guide" && pass "commit 5 message in log" || fail "commit 5 message missing"
# --- Phase 8: Verify git status is clean ----------------------------------
echo "--- Phase 8: Verify git status is clean ---"
cd "$MOUNT_CLONE"
STATUS=$(git status --porcelain)
if [[ -z "$STATUS" ]]; then
pass "git status is clean"
else
fail "git status has changes: $STATUS"
fi
# --- Results ---------------------------------------------------------------
echo ""
echo "========================================"
echo " Results: $PASS passed, $FAIL failed"
echo "========================================"
if [[ "$FAIL" -gt 0 ]]; then
exit 1
fi
+10 -10
View File
@@ -46,8 +46,8 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12
github.com/karlseguin/ccache/v2 v2.0.8
github.com/klauspost/compress v1.18.4
github.com/klauspost/reedsolomon v1.13.0
github.com/klauspost/compress v1.18.5
github.com/klauspost/reedsolomon v1.13.3
github.com/kurin/blazer v0.5.3
github.com/linxGnu/grocksdb v1.10.7
github.com/mailru/easyjson v0.9.1 // indirect
@@ -91,14 +91,14 @@ require (
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.45.0
gocloud.dev/pubsub/natspubsub v0.45.0
gocloud.dev/pubsub/rabbitpubsub v0.44.0
golang.org/x/crypto v0.48.0
gocloud.dev/pubsub/rabbitpubsub v0.45.0
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/net v0.51.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.267.0
@@ -131,7 +131,7 @@ require (
github.com/cognusion/imaging v1.0.2
github.com/fluent/fluent-logger-golang v1.10.1
github.com/getsentry/sentry-go v0.43.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50
github.com/hashicorp/raft v1.7.3
@@ -149,7 +149,7 @@ require (
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.18.0
github.com/schollz/progressbar/v3 v3.19.0
github.com/seaweedfs/go-fuse/v2 v2.9.1
github.com/seaweedfs/go-fuse/v2 v2.9.2
github.com/shirou/gopsutil/v4 v4.26.2
github.com/tarantool/go-tarantool/v2 v2.4.2
github.com/testcontainers/testcontainers-go v0.40.0
@@ -159,7 +159,7 @@ require (
github.com/ydb-platform/ydb-go-sdk/v3 v3.125.3
go.etcd.io/etcd/client/pkg/v3 v3.6.7
go.uber.org/atomic v1.11.0
golang.org/x/sync v0.19.0
golang.org/x/sync v0.20.0
golang.org/x/tools/godoc v0.1.0-deprecated
google.golang.org/grpc/security/advancedtls v1.0.0
)
@@ -498,7 +498,7 @@ require (
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
+20 -18
View File
@@ -1083,8 +1083,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@@ -1475,15 +1475,15 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc=
github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU=
@@ -1840,6 +1840,8 @@ github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564 h1:Tg
github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564/go.mod h1:JSKCh6uCHBz91lQYFYHCyTrSVIPge4SUFVn28iwMNB0=
github.com/seaweedfs/go-fuse/v2 v2.9.1 h1:gnKmfrKreCRGJmekGz5WMnNZqXEf9s9+V2hdWQdvx88=
github.com/seaweedfs/go-fuse/v2 v2.9.1/go.mod h1:zABdmWEa6A0bwaBeEOBUeUkGIZlxUhcdv+V1Dcc/U/I=
github.com/seaweedfs/go-fuse/v2 v2.9.2 h1:IfP/yFjLGO4rALcJY2Gb39PlebHxLnj7dkIiQAjFres=
github.com/seaweedfs/go-fuse/v2 v2.9.2/go.mod h1:zABdmWEa6A0bwaBeEOBUeUkGIZlxUhcdv+V1Dcc/U/I=
github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz30=
github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
github.com/seaweedfs/raft v1.1.7 h1:3mVJZ2p4rdvBtbbrHROPjYKtH+q5qjMBc56G6VRu1kA=
@@ -2184,8 +2186,8 @@ gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY=
gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI=
gocloud.dev/pubsub/natspubsub v0.45.0 h1:kfCupVejeynIQcS1GaIvkY3Nj/acLlM5yPWyxZN8wJ8=
gocloud.dev/pubsub/natspubsub v0.45.0/go.mod h1:WU5SMDWuF7CCr2U8UpbrEONYYFOmvigCAvTpOOpUvYE=
gocloud.dev/pubsub/rabbitpubsub v0.44.0 h1:MpRIO6XJ/JTqrlUWt3CxwDe1LvaiXUVu4sS5cv4f/AM=
gocloud.dev/pubsub/rabbitpubsub v0.44.0/go.mod h1:BB9+qT3r6g4M5+4asiXaEeqw4QAOzsWusO5krYaqkdA=
gocloud.dev/pubsub/rabbitpubsub v0.45.0 h1:hlejKjBFYQBUAj7ZsHKgW0JOER/3XUqrMFFxBOFUIOU=
gocloud.dev/pubsub/rabbitpubsub v0.45.0/go.mod h1:Kqq6gppGY/RTrRQkKSHZq+/qbCufPEoQ0E1V3X389zM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -2208,8 +2210,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -2344,8 +2346,8 @@ golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2397,8 +2399,8 @@ golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2523,8 +2525,8 @@ golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2545,8 +2547,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -15,6 +15,7 @@ metadata:
{{- toYaml .Values.s3.annotations | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.s3.service.type }}
internalTrafficPolicy: {{ .Values.s3.internalTrafficPolicy | default "Cluster" }}
{{- $td := .Values.s3.trafficDistribution | default .Values.filer.s3.trafficDistribution }}
{{- if and (semverCompare ">=1.31-0" .Capabilities.KubeVersion.GitVersion) $td }}
@@ -15,6 +15,7 @@ metadata:
{{- toYaml .Values.sftp.annotations | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.sftp.service.type }}
internalTrafficPolicy: {{ .Values.sftp.internalTrafficPolicy | default "Cluster" }}
ports:
- name: "swfs-sftp"
@@ -31,4 +32,4 @@ spec:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: sftp
{{- end }}
{{- end }}
+8
View File
@@ -1104,6 +1104,10 @@ s3:
annotations: {}
tls: []
# Service settings
service:
type: ClusterIP
icebergIngress:
enabled: false
className: ""
@@ -1185,6 +1189,10 @@ sftp:
failureThreshold: 100
timeoutSeconds: 10
# Service settings
service:
type: ClusterIP
admin:
enabled: false
imageOverride: null
+45 -1
View File
@@ -33,6 +33,9 @@ service SeaweedFiler {
rpc StreamRenameEntry (StreamRenameEntryRequest) returns (stream StreamRenameEntryResponse) {
}
rpc StreamMutateEntry (stream StreamMutateEntryRequest) returns (stream StreamMutateEntryResponse) {
}
rpc AssignVolume (AssignVolumeRequest) returns (AssignVolumeResponse) {
}
@@ -203,9 +206,21 @@ message CreateEntryRequest {
bool skip_check_parent_directory = 6;
}
// Structured error codes for filer entry operations.
// Values are stable — do not reorder or reuse numbers.
enum FilerError {
OK = 0;
ENTRY_NAME_TOO_LONG = 1; // name exceeds max_file_name_length
PARENT_IS_FILE = 2; // parent path component is a file, not a directory
EXISTING_IS_DIRECTORY = 3; // cannot overwrite directory with file
EXISTING_IS_FILE = 4; // cannot overwrite file with directory
ENTRY_ALREADY_EXISTS = 5; // O_EXCL and entry already exists
}
message CreateEntryResponse {
string error = 1;
string error = 1; // kept for human readability + backward compat
SubscribeMetadataResponse metadata_event = 2;
FilerError error_code = 3; // machine-readable error code
}
message UpdateEntryRequest {
@@ -523,3 +538,32 @@ message TransferLocksRequest {
}
message TransferLocksResponse {
}
//////////////////////////////////////////////////
// StreamMutateEntry: ordered bidirectional streaming for all filer mutations.
// All create/update/delete/rename operations from a single mount go through
// one stream, preserving mutation ordering and eliminating per-request
// connection overhead.
message StreamMutateEntryRequest {
uint64 request_id = 1;
oneof request {
CreateEntryRequest create_request = 2;
UpdateEntryRequest update_request = 3;
DeleteEntryRequest delete_request = 4;
StreamRenameEntryRequest rename_request = 5;
}
}
message StreamMutateEntryResponse {
uint64 request_id = 1;
bool is_last = 2; // always true except for rename, which sends multiple events
oneof response {
CreateEntryResponse create_response = 3;
UpdateEntryResponse update_response = 4;
DeleteEntryResponse delete_response = 5;
StreamRenameEntryResponse rename_response = 6;
}
string error = 7; // human-readable error message when the operation failed
int32 errno = 8; // POSIX errno (e.g. ENOENT=2, ENOTEMPTY=66) for direct FUSE status mapping
}
+90 -138
View File
@@ -15,25 +15,21 @@ import (
"github.com/stretchr/testify/require"
)
// FuseTestFramework provides utilities for FUSE integration testing
// FuseTestFramework provides utilities for FUSE integration testing.
// It starts a single "weed mini" process (master+volume+filer in one)
// and a separate "weed mount" process for the FUSE filesystem.
type FuseTestFramework struct {
t *testing.T
tempDir string
mountPoint string
dataDir string
logDir string
masterProcess *os.Process
volumeProcess *os.Process
filerProcess *os.Process
mountProcess *os.Process
masterAddr string
volumeAddr string
filerAddr string
masterPort int
volumePort int
filerPort int
weedBinary string
isSetup bool
t *testing.T
tempDir string
mountPoint string
dataDir string
logDir string
miniProcess *os.Process
mountProcess *os.Process
filerAddr string
filerPort int
weedBinary string
isSetup bool
}
// TestConfig holds configuration for FUSE tests
@@ -63,8 +59,6 @@ func DefaultTestConfig() *TestConfig {
}
// NewFuseTestFramework creates a new FUSE testing framework.
// Each instance allocates its own free ports so multiple tests can run
// sequentially without port conflicts from slow cleanup.
func NewFuseTestFramework(t *testing.T, config *TestConfig) *FuseTestFramework {
if config == nil {
config = DefaultTestConfig()
@@ -73,8 +67,6 @@ func NewFuseTestFramework(t *testing.T, config *TestConfig) *FuseTestFramework {
tempDir, err := os.MkdirTemp("", "seaweedfs_fuse_test_")
require.NoError(t, err)
masterPort := freePort(t)
volumePort := freePort(t)
filerPort := freePort(t)
return &FuseTestFramework{
@@ -83,11 +75,7 @@ func NewFuseTestFramework(t *testing.T, config *TestConfig) *FuseTestFramework {
mountPoint: filepath.Join(tempDir, "mount"),
dataDir: filepath.Join(tempDir, "data"),
logDir: filepath.Join(tempDir, "logs"),
masterPort: masterPort,
volumePort: volumePort,
filerPort: filerPort,
masterAddr: fmt.Sprintf("127.0.0.1:%d", masterPort),
volumeAddr: fmt.Sprintf("127.0.0.1:%d", volumePort),
filerAddr: fmt.Sprintf("127.0.0.1:%d", filerPort),
weedBinary: findWeedBinary(),
isSetup: false,
@@ -97,63 +85,50 @@ func NewFuseTestFramework(t *testing.T, config *TestConfig) *FuseTestFramework {
// freePort asks the OS for a free TCP port.
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port
const (
minServicePort = 20000
maxServicePort = 55535 // SeaweedFS gRPC service uses httpPort + 10000.
)
portCount := maxServicePort - minServicePort + 1
start := minServicePort + int(time.Now().UnixNano()%int64(portCount))
for attempt := 0; attempt < 512; attempt++ {
port := minServicePort + (start-minServicePort+attempt)%portCount
l, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
continue
}
l.Close()
return port
}
t.Fatalf("failed to allocate port <= %d after repeated attempts", maxServicePort)
return 0
}
// Setup starts SeaweedFS cluster and mounts FUSE filesystem
// Setup starts "weed mini" and mounts the FUSE filesystem.
func (f *FuseTestFramework) Setup(config *TestConfig) error {
if f.isSetup {
return fmt.Errorf("framework already setup")
}
// Create all required directories upfront
dirs := []string{
f.mountPoint,
f.logDir,
filepath.Join(f.dataDir, "master"),
filepath.Join(f.dataDir, "volume"),
}
dirs := []string{f.mountPoint, f.logDir, f.dataDir}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", dir, err)
}
}
// Start master
if err := f.startMaster(config); err != nil {
return fmt.Errorf("failed to start master: %v", err)
// Start weed mini (master + volume + filer in one process)
if err := f.startMini(config); err != nil {
return fmt.Errorf("failed to start weed mini: %v", err)
}
// Wait for master to be ready
if err := f.waitForService(f.masterAddr, 30*time.Second); err != nil {
f.dumpLog("master")
return fmt.Errorf("master not ready: %v", err)
}
// Start volume servers
if err := f.startVolumeServers(config); err != nil {
return fmt.Errorf("failed to start volume servers: %v", err)
}
// Wait for volume server to be ready
if err := f.waitForService(f.volumeAddr, 30*time.Second); err != nil {
f.dumpLog("volume")
return fmt.Errorf("volume server not ready: %v", err)
}
// Start filer
if err := f.startFiler(config); err != nil {
return fmt.Errorf("failed to start filer: %v", err)
}
// Wait for filer to be ready
// Wait for filer to be ready (mini starts all services on filerPort)
if err := f.waitForService(f.filerAddr, 30*time.Second); err != nil {
f.dumpLog("filer")
return fmt.Errorf("filer not ready: %v", err)
f.dumpLog("mini")
return fmt.Errorf("weed mini not ready: %v", err)
}
// Mount FUSE filesystem
@@ -171,27 +146,39 @@ func (f *FuseTestFramework) Setup(config *TestConfig) error {
return nil
}
// Cleanup stops all processes and removes temporary files
// Cleanup stops all processes and removes temporary files.
// If the test failed, it dumps logs automatically.
func (f *FuseTestFramework) Cleanup() {
if f.t.Failed() {
f.DumpLogs()
}
if f.mountProcess != nil {
f.unmountFuse()
}
// Stop processes in reverse order
processes := []*os.Process{f.mountProcess, f.filerProcess, f.volumeProcess, f.masterProcess}
for _, proc := range processes {
for _, proc := range []*os.Process{f.mountProcess, f.miniProcess} {
if proc != nil {
proc.Signal(syscall.SIGTERM)
proc.Wait()
}
}
// Remove temp directory
f.copyLogsForCI()
if !DefaultTestConfig().SkipCleanup {
os.RemoveAll(f.tempDir)
}
}
// DumpLogs prints the tail of all SeaweedFS process logs to test output.
func (f *FuseTestFramework) DumpLogs() {
for _, name := range []string{"mini", "mount"} {
f.dumpLog(name)
}
}
// GetMountPoint returns the FUSE mount point path
func (f *FuseTestFramework) GetMountPoint() string {
return f.mountPoint
@@ -223,82 +210,56 @@ func (f *FuseTestFramework) startProcess(name string, args []string) (*os.Proces
}
// dumpLog prints the last lines of a process log file to the test output
// for debugging when a service fails to start.
// for debugging when a service fails to start or a test fails.
func (f *FuseTestFramework) dumpLog(name string) {
data, err := os.ReadFile(filepath.Join(f.logDir, name+".log"))
if err != nil {
f.t.Logf("[%s log] (not available: %v)", name, err)
return
}
// Truncate to last 2KB to keep output manageable
if len(data) > 2048 {
data = data[len(data)-2048:]
// Show last 16KB on failure for meaningful context.
const maxTail = 16 * 1024
if len(data) > maxTail {
data = data[len(data)-maxTail:]
}
f.t.Logf("[%s log tail]\n%s", name, string(data))
f.t.Logf("[%s log tail (%d bytes)]\n%s", name, len(data), string(data))
}
// startMaster starts the SeaweedFS master server
func (f *FuseTestFramework) startMaster(config *TestConfig) error {
// Do NOT set -port.grpc explicitly. SeaweedFS convention is gRPC = HTTP + 10000.
// Volume/filer discover the master gRPC port by this convention, so overriding
// it breaks inter-service communication.
// copyLogsForCI copies SeaweedFS process logs to /tmp/seaweedfs-fuse-logs/
// so the CI workflow can upload them as artifacts.
func (f *FuseTestFramework) copyLogsForCI() {
ciLogDir := "/tmp/seaweedfs-fuse-logs"
os.MkdirAll(ciLogDir, 0755)
for _, name := range []string{"mini", "mount"} {
src := filepath.Join(f.logDir, name+".log")
data, err := os.ReadFile(src)
if err != nil {
continue
}
os.WriteFile(filepath.Join(ciLogDir, name+".log"), data, 0644)
}
}
// startMini starts "weed mini" which runs master+volume+filer in one process.
func (f *FuseTestFramework) startMini(config *TestConfig) error {
args := []string{
"master",
"mini",
"-dir=" + f.dataDir,
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(f.masterPort),
"-mdir=" + filepath.Join(f.dataDir, "master"),
"-filer.port=" + strconv.Itoa(f.filerPort),
"-s3=false",
"-webdav=false",
"-admin.ui=false",
}
if config.EnableDebug {
args = append(args, "-v=4")
}
proc, err := f.startProcess("master", args)
proc, err := f.startProcess("mini", args)
if err != nil {
return err
}
f.masterProcess = proc
return nil
}
// startVolumeServers starts SeaweedFS volume servers
func (f *FuseTestFramework) startVolumeServers(config *TestConfig) error {
args := []string{
"volume",
"-master=127.0.0.1:" + strconv.Itoa(f.masterPort),
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(f.volumePort),
"-dir=" + filepath.Join(f.dataDir, "volume"),
fmt.Sprintf("-max=%d", config.NumVolumes),
}
if config.EnableDebug {
args = append(args, "-v=4")
}
proc, err := f.startProcess("volume", args)
if err != nil {
return err
}
f.volumeProcess = proc
return nil
}
// startFiler starts the SeaweedFS filer server
func (f *FuseTestFramework) startFiler(config *TestConfig) error {
args := []string{
"filer",
"-master=127.0.0.1:" + strconv.Itoa(f.masterPort),
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(f.filerPort),
}
if config.EnableDebug {
args = append(args, "-v=4")
}
proc, err := f.startProcess("filer", args)
if err != nil {
return err
}
f.filerProcess = proc
f.miniProcess = proc
return nil
}
@@ -371,9 +332,7 @@ func (f *FuseTestFramework) waitForService(addr string, timeout time.Duration) e
func (f *FuseTestFramework) waitForMount(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
// Check if mount point is accessible
if _, err := os.Stat(f.mountPoint); err == nil {
// Try to list directory
if _, err := os.ReadDir(f.mountPoint); err == nil {
return nil
}
@@ -384,19 +343,13 @@ func (f *FuseTestFramework) waitForMount(timeout time.Duration) error {
}
// findWeedBinary locates the weed binary.
// Checks PATH first (most reliable in CI where the binary is installed to
// /usr/local/bin), then falls back to relative paths. Each candidate is
// verified to be a regular file so that a source directory named "weed"
// is never mistaken for the binary.
func findWeedBinary() string {
// PATH lookup first — works in CI and when weed is installed globally.
if p, err := exec.LookPath("weed"); err == nil {
return p
}
// Relative paths for local development (run from test/fuse_integration/).
candidates := []string{
"../../weed/weed", // built in-tree: weed/weed
"../../weed/weed",
"./weed",
"../weed",
}
@@ -407,7 +360,6 @@ func findWeedBinary() string {
}
}
// Default fallback — will fail with a clear "not found" at exec time.
return "weed"
}
@@ -0,0 +1,348 @@
package fuse_test
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGitOperations exercises git clone, checkout, and pull on a FUSE mount.
//
// The test creates a bare repo on the mount (acting as a remote), clones it,
// makes commits, pushes, then clones from the mount into an on-mount working
// directory. It pushes additional commits, checks out an older revision in the
// on-mount clone, and runs git pull to fast-forward with real changes —
// verifying file content integrity at each step.
func TestGitOperations(t *testing.T) {
framework := NewFuseTestFramework(t, DefaultTestConfig())
defer framework.Cleanup()
require.NoError(t, framework.Setup(DefaultTestConfig()))
mountPoint := framework.GetMountPoint()
// We need a local scratch dir (not on the mount) for the "developer" clone.
localDir, err := os.MkdirTemp("", "git_ops_local_")
require.NoError(t, err)
defer os.RemoveAll(localDir)
t.Run("CloneAndPull", func(t *testing.T) {
testGitCloneAndPull(t, mountPoint, localDir)
})
}
func testGitCloneAndPull(t *testing.T, mountPoint, localDir string) {
bareRepo := filepath.Join(mountPoint, "repo.git")
localClone := filepath.Join(localDir, "clone")
mountClone := filepath.Join(mountPoint, "working")
// ---- Phase 1: Create bare repo on the mount ----
t.Log("Phase 1: create bare repo on mount")
gitRun(t, "", "init", "--bare", bareRepo)
// ---- Phase 2: Clone locally, make initial commits, push ----
t.Log("Phase 2: clone locally, commit, push")
gitRun(t, "", "clone", bareRepo, localClone)
gitRun(t, localClone, "config", "user.email", "test@seaweedfs.test")
gitRun(t, localClone, "config", "user.name", "Test")
// Commit 1
writeFile(t, localClone, "README.md", "hello world\n")
mkdirAll(t, localClone, "src")
writeFile(t, localClone, "src/main.go", `package main; import "fmt"; func main() { fmt.Println("v1") }`)
gitRun(t, localClone, "add", "-A")
gitRun(t, localClone, "commit", "-m", "initial commit")
commit1 := gitOutput(t, localClone, "rev-parse", "HEAD")
// Commit 2: bulk files
mkdirAll(t, localClone, "data")
for i := 1; i <= 20; i++ {
name := filepath.Join("data", "file-"+leftPad(i, 3)+".txt")
writeFile(t, localClone, name, "content-"+strconv.Itoa(i)+"\n")
}
gitRun(t, localClone, "add", "-A")
gitRun(t, localClone, "commit", "-m", "add data files")
commit2 := gitOutput(t, localClone, "rev-parse", "HEAD")
// Commit 3: modify + new dir
writeFile(t, localClone, "src/main.go", `package main; import "fmt"; func main() { fmt.Println("v2") }`)
writeFile(t, localClone, "README.md", "hello world\n# Updated\n")
mkdirAll(t, localClone, "docs")
writeFile(t, localClone, "docs/guide.md", "documentation\n")
gitRun(t, localClone, "add", "-A")
gitRun(t, localClone, "commit", "-m", "update src and add docs")
commit3 := gitOutput(t, localClone, "rev-parse", "HEAD")
branch := gitOutput(t, localClone, "rev-parse", "--abbrev-ref", "HEAD")
gitRun(t, localClone, "push", "origin", branch)
// ---- Phase 3: Clone from mount bare repo into on-mount working dir ----
t.Log("Phase 3: clone from mount bare repo to on-mount working dir")
gitRun(t, "", "clone", bareRepo, mountClone)
assertFileContains(t, filepath.Join(mountClone, "README.md"), "# Updated")
assertFileContains(t, filepath.Join(mountClone, "src/main.go"), "v2")
assertFileExists(t, filepath.Join(mountClone, "docs/guide.md"))
assertFileExists(t, filepath.Join(mountClone, "data/file-020.txt"))
head := gitOutput(t, mountClone, "rev-parse", "HEAD")
assert.Equal(t, commit3, head, "on-mount clone HEAD should be commit 3")
dataFiles := countFiles(t, filepath.Join(mountClone, "data"))
assert.Equal(t, 20, dataFiles, "data/ should have 20 files")
// ---- Phase 4: Push more commits from the local clone ----
t.Log("Phase 4: push more commits")
for i := 21; i <= 50; i++ {
name := filepath.Join("data", "file-"+leftPad(i, 3)+".txt")
writeFile(t, localClone, name, "content-"+strconv.Itoa(i)+"\n")
}
writeFile(t, localClone, "src/main.go", `package main; import "fmt"; func main() { fmt.Println("v3") }`)
gitRun(t, localClone, "add", "-A")
gitRun(t, localClone, "commit", "-m", "expand data and update to v3")
commit4 := gitOutput(t, localClone, "rev-parse", "HEAD")
_ = commit4
gitRun(t, localClone, "mv", "docs/guide.md", "docs/manual.md")
gitRun(t, localClone, "rm", "data/file-001.txt")
gitRun(t, localClone, "commit", "-m", "rename guide, remove file-001")
commit5 := gitOutput(t, localClone, "rev-parse", "HEAD")
gitRun(t, localClone, "push", "origin", branch)
// ---- Phase 5: Reset to older revision in on-mount clone ----
t.Log("Phase 5: reset to older revision on mount clone")
ensureMountClone(t, bareRepo, mountClone)
gitRun(t, mountClone, "reset", "--hard", commit2)
resetHead := gitOutput(t, mountClone, "rev-parse", "HEAD")
assert.Equal(t, commit2, resetHead, "should be at commit 2")
assertFileContains(t, filepath.Join(mountClone, "src/main.go"), "v1")
assertFileNotExists(t, filepath.Join(mountClone, "docs/guide.md"))
// ---- Phase 6: Pull with real changes ----
t.Log("Phase 6: pull with real fast-forward changes")
ensureMountClone(t, bareRepo, mountClone)
// After git reset --hard, give the FUSE mount a moment to settle its
// metadata cache. On slow CI, the working directory can briefly appear
// missing to a new subprocess (git pull → unpack-objects).
waitForDir(t, mountClone)
oldHead := gitOutput(t, mountClone, "rev-parse", "HEAD")
assert.Equal(t, commit2, oldHead, "should be at commit 2 before pull")
gitRun(t, mountClone, "pull")
newHead := gitOutput(t, mountClone, "rev-parse", "HEAD")
assert.Equal(t, commit5, newHead, "HEAD should be commit 5 after pull")
assertFileContains(t, filepath.Join(mountClone, "src/main.go"), "v3")
assertFileExists(t, filepath.Join(mountClone, "docs/manual.md"))
assertFileNotExists(t, filepath.Join(mountClone, "docs/guide.md"))
assertFileNotExists(t, filepath.Join(mountClone, "data/file-001.txt"))
assertFileExists(t, filepath.Join(mountClone, "data/file-050.txt"))
finalCount := countFiles(t, filepath.Join(mountClone, "data"))
assert.Equal(t, 49, finalCount, "data/ should have 49 files after pull")
// ---- Phase 7: Verify git log and status ----
t.Log("Phase 7: verify log and status")
logOutput := gitOutput(t, mountClone, "log", "--format=%s")
lines := strings.Split(strings.TrimSpace(logOutput), "\n")
assert.Equal(t, 5, len(lines), "should have 5 commits in log")
assert.Contains(t, logOutput, "initial commit")
assert.Contains(t, logOutput, "expand data")
assert.Contains(t, logOutput, "rename guide")
status := gitOutput(t, mountClone, "status", "--porcelain")
assert.Empty(t, status, "git status should be clean")
_ = commit1 // used for documentation; not needed in assertions
}
// --- helpers ---
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
gitRunWithRetry(t, dir, args...)
}
func gitOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
return gitRunWithRetry(t, dir, args...)
}
// gitRunWithRetry runs a git command with retries to handle transient FUSE
// I/O errors on slow CI runners (e.g. "Could not write new index file",
// "failed to stat", "unpack-objects failed").
func gitRunWithRetry(t *testing.T, dir string, args ...string) string {
t.Helper()
const (
maxRetries = 6
dirWait = 10 * time.Second
)
var out []byte
var err error
for i := 0; i < maxRetries; i++ {
if dir != "" && !waitForDirEventually(t, dir, dirWait) {
out = []byte("directory missing: " + dir)
err = &os.PathError{Op: "stat", Path: dir, Err: os.ErrNotExist}
} else {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err = cmd.CombinedOutput()
}
if err == nil {
return strings.TrimSpace(string(out))
}
if i < maxRetries-1 {
t.Logf("git %s attempt %d failed (retrying): %s", strings.Join(args, " "), i+1, string(out))
if dir != "" {
refreshDirEntry(t, dir)
}
if repoPath := extractGitRepoPath(string(out)); repoPath != "" {
_ = exec.Command("git", "init", "--bare", repoPath).Run()
waitForBareRepoEventually(t, repoPath, 5*time.Second)
}
time.Sleep(500 * time.Millisecond)
}
}
require.NoError(t, err, "git %s failed after %d attempts: %s", strings.Join(args, " "), maxRetries, string(out))
return ""
}
func writeFile(t *testing.T, base, rel, content string) {
t.Helper()
p := filepath.Join(base, rel)
require.NoError(t, os.WriteFile(p, []byte(content), 0644))
}
func mkdirAll(t *testing.T, base, rel string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(base, rel), 0755))
}
func assertFileExists(t *testing.T, path string) {
t.Helper()
_, err := os.Stat(path)
require.NoError(t, err, "expected file to exist: %s", path)
}
func assertFileNotExists(t *testing.T, path string) {
t.Helper()
_, err := os.Stat(path)
require.True(t, os.IsNotExist(err), "expected file not to exist: %s", path)
}
func assertFileContains(t *testing.T, path, substr string) {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err, "failed to read %s", path)
assert.Contains(t, string(data), substr, "file %s should contain %q", path, substr)
}
func countFiles(t *testing.T, dir string) int {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err, "failed to read dir %s", dir)
count := 0
for _, e := range entries {
if !e.IsDir() {
count++
}
}
return count
}
func waitForDir(t *testing.T, dir string) {
t.Helper()
if !waitForDirEventually(t, dir, 10*time.Second) {
t.Fatalf("directory %s did not appear within 10s", dir)
}
}
func waitForDirEventually(t *testing.T, dir string, timeout time.Duration) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Stat(dir); err == nil {
return true
}
time.Sleep(100 * time.Millisecond)
}
return false
}
func refreshDirEntry(t *testing.T, dir string) {
t.Helper()
parent := filepath.Dir(dir)
_, _ = os.ReadDir(parent)
}
func waitForBareRepoEventually(t *testing.T, bareRepo string, timeout time.Duration) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if isBareRepo(bareRepo) {
return true
}
refreshDirEntry(t, bareRepo)
time.Sleep(150 * time.Millisecond)
}
return false
}
func isBareRepo(bareRepo string) bool {
required := []string{
filepath.Join(bareRepo, "HEAD"),
filepath.Join(bareRepo, "config"),
}
for _, p := range required {
if _, err := os.Stat(p); err != nil {
return false
}
}
return true
}
func ensureMountClone(t *testing.T, bareRepo, mountClone string) {
t.Helper()
if _, err := os.Stat(mountClone); err == nil {
return
} else if !os.IsNotExist(err) {
require.NoError(t, err)
}
t.Logf("mount clone missing, re-cloning from %s", bareRepo)
gitRun(t, "", "clone", bareRepo, mountClone)
}
var gitRepoPathRe = regexp.MustCompile(`'([^']+)' does not appear to be a git repository`)
func extractGitRepoPath(output string) string {
if match := gitRepoPathRe.FindStringSubmatch(output); len(match) > 1 {
return match[1]
}
return ""
}
func leftPad(n, width int) string {
s := strconv.Itoa(n)
for len(s) < width {
s = "0" + s
}
return s
}
@@ -0,0 +1,765 @@
package fuse_test
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- fcntl helpers (used by both test process and subprocess) ---
func fcntlSetLk(f *os.File, typ int16, start, len int64) error {
flk := syscall.Flock_t{
Type: typ,
Whence: 0, // SEEK_SET
Start: start,
Len: len, // 0 means to EOF
}
return syscall.FcntlFlock(f.Fd(), syscall.F_SETLK, &flk)
}
func fcntlSetLkw(f *os.File, typ int16, start, len int64) error {
flk := syscall.Flock_t{
Type: typ,
Whence: 0,
Start: start,
Len: len,
}
return syscall.FcntlFlock(f.Fd(), syscall.F_SETLKW, &flk)
}
func fcntlGetLk(f *os.File, typ int16, start, len int64) (syscall.Flock_t, error) {
flk := syscall.Flock_t{
Type: typ,
Whence: 0,
Start: start,
Len: len,
}
err := syscall.FcntlFlock(f.Fd(), syscall.F_GETLK, &flk)
return flk, err
}
// --- Subprocess entry point for fcntl lock tests ---
//
// POSIX fcntl locks are per-process (the kernel uses the process's files_struct
// as lock owner). To test inter-process lock conflicts over FUSE, contending
// locks must come from separate processes.
//
// Actions:
// hold — acquire F_SETLK, print "LOCKED\n", wait for stdin close, exit
// hold-blocking — print "WAITING\n", acquire F_SETLKW, print "LOCKED\n", wait for stdin close, exit
// try — try F_SETLK, print "OK\n" or "EAGAIN\n", exit
// getlk — do F_GETLK, print lock type as integer, exit
func TestFcntlLockHelper(t *testing.T) {
action := os.Getenv("LOCK_ACTION")
if action == "" {
t.Skip("subprocess helper — not invoked directly")
}
filePath := os.Getenv("LOCK_FILE")
lockType, err := parseLockType(os.Getenv("LOCK_TYPE"))
if err != nil {
fmt.Fprintf(os.Stderr, "lock type: %v\n", err)
os.Exit(1)
}
start, _ := strconv.ParseInt(os.Getenv("LOCK_START"), 10, 64)
length, _ := strconv.ParseInt(os.Getenv("LOCK_LEN"), 10, 64)
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "open: %v\n", err)
os.Exit(1)
}
defer f.Close()
switch action {
case "hold":
if err := fcntlSetLk(f, lockType, start, length); err != nil {
fmt.Fprintf(os.Stderr, "setlk: %v\n", err)
os.Exit(1)
}
fmt.Println("LOCKED")
os.Stdout.Sync()
io.ReadAll(os.Stdin) // block until parent closes pipe
case "hold-blocking":
fmt.Println("WAITING")
os.Stdout.Sync()
if err := fcntlSetLkw(f, lockType, start, length); err != nil {
fmt.Fprintf(os.Stderr, "setlkw: %v\n", err)
os.Exit(1)
}
fmt.Println("LOCKED")
os.Stdout.Sync()
io.ReadAll(os.Stdin)
case "try":
if err := fcntlSetLk(f, lockType, start, length); err != nil {
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EACCES) {
fmt.Println("EAGAIN")
} else {
fmt.Fprintf(os.Stderr, "unexpected setlk error: %v\n", err)
os.Exit(1)
}
} else {
fmt.Println("OK")
}
os.Stdout.Sync()
case "getlk":
flk := syscall.Flock_t{Type: lockType, Whence: 0, Start: start, Len: length}
if err := syscall.FcntlFlock(f.Fd(), syscall.F_GETLK, &flk); err != nil {
fmt.Fprintf(os.Stderr, "getlk: %v\n", err)
os.Exit(1)
}
fmt.Println(flk.Type)
os.Stdout.Sync()
default:
fmt.Fprintf(os.Stderr, "unknown action: %s\n", action)
os.Exit(1)
}
os.Exit(0)
}
func parseLockType(s string) (int16, error) {
switch s {
case "F_RDLCK":
return syscall.F_RDLCK, nil
case "F_WRLCK":
return syscall.F_WRLCK, nil
default:
return 0, fmt.Errorf("unknown lock type %q", s)
}
}
// --- Subprocess coordination helpers ---
// lockHolder is a subprocess holding an fcntl lock. A background goroutine
// reads stdout and signals the waiting/locked channels as the subprocess
// progresses through the lock lifecycle.
type lockHolder struct {
cmd *exec.Cmd
stdin io.WriteCloser
waiting chan struct{} // closed when subprocess prints "WAITING"
locked chan struct{} // closed when subprocess prints "LOCKED"
}
// startLockHolder launches a subprocess that acquires an fcntl lock and holds
// it until Release is called.
func startLockHolder(t *testing.T, path string, lockType string, start, length int64) *lockHolder {
t.Helper()
return startLockProcess(t, "hold", path, lockType, start, length)
}
// startBlockingLockHolder launches a subprocess that tries F_SETLKW.
// It blocks until the lock is available, then holds it until Release is called.
func startBlockingLockHolder(t *testing.T, path string, lockType string, start, length int64) *lockHolder {
t.Helper()
return startLockProcess(t, "hold-blocking", path, lockType, start, length)
}
func startLockProcess(t *testing.T, action, path, lockType string, start, length int64) *lockHolder {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestFcntlLockHelper$")
cmd.Env = append(os.Environ(),
"LOCK_ACTION="+action,
"LOCK_FILE="+path,
"LOCK_TYPE="+lockType,
fmt.Sprintf("LOCK_START=%d", start),
fmt.Sprintf("LOCK_LEN=%d", length),
)
stdin, err := cmd.StdinPipe()
require.NoError(t, err)
stdoutPipe, err := cmd.StdoutPipe()
require.NoError(t, err)
cmd.Stderr = os.Stderr
require.NoError(t, cmd.Start())
waiting := make(chan struct{})
locked := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stdoutPipe)
for scanner.Scan() {
switch strings.TrimSpace(scanner.Text()) {
case "WAITING":
select {
case <-waiting:
default:
close(waiting)
}
case "LOCKED":
select {
case <-locked:
default:
close(locked)
}
}
}
}()
return &lockHolder{cmd: cmd, stdin: stdin, waiting: waiting, locked: locked}
}
// WaitWaiting waits for the subprocess to print "WAITING" before trying to
// assert that a blocking lock request is still blocked.
func (h *lockHolder) WaitWaiting(t *testing.T, timeout time.Duration) {
t.Helper()
select {
case <-h.waiting:
// OK
case <-time.After(timeout):
t.Fatal("timed out waiting for subprocess to start blocking lock attempt")
}
}
// WaitLocked waits for the subprocess to acquire the lock and signal "LOCKED".
func (h *lockHolder) WaitLocked(t *testing.T, timeout time.Duration) {
t.Helper()
select {
case <-h.locked:
// OK
case <-time.After(timeout):
t.Fatal("timed out waiting for subprocess to acquire lock")
}
}
// IsLocked checks whether the subprocess has signaled "LOCKED" within 200ms.
func (h *lockHolder) IsLocked() bool {
select {
case <-h.waiting:
// The subprocess has reached the blocking lock attempt.
case <-h.locked:
return true
case <-time.After(200 * time.Millisecond):
return false
}
select {
case <-h.locked:
return true
case <-time.After(200 * time.Millisecond):
return false
}
}
// Release signals the subprocess to exit and waits for it.
func (h *lockHolder) Release(t *testing.T) {
t.Helper()
h.stdin.Close()
_ = h.cmd.Wait()
}
// tryLockInSubprocess tries a non-blocking fcntl lock in a subprocess.
// Returns nil if the lock was acquired, syscall.EAGAIN if it was denied.
func tryLockInSubprocess(t *testing.T, path, lockType string, start, length int64) error {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestFcntlLockHelper$")
cmd.Env = append(os.Environ(),
"LOCK_ACTION=try",
"LOCK_FILE="+path,
"LOCK_TYPE="+lockType,
fmt.Sprintf("LOCK_START=%d", start),
fmt.Sprintf("LOCK_LEN=%d", length),
)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
require.NoError(t, err, "subprocess failed to run")
result := strings.TrimSpace(string(out))
if result == "EAGAIN" {
return syscall.EAGAIN
}
require.Equal(t, "OK", result, "unexpected subprocess output")
return nil
}
// getLkInSubprocess does F_GETLK in a subprocess and returns the lock type.
func getLkInSubprocess(t *testing.T, path, lockType string, start, length int64) int16 {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestFcntlLockHelper$")
cmd.Env = append(os.Environ(),
"LOCK_ACTION=getlk",
"LOCK_FILE="+path,
"LOCK_TYPE="+lockType,
fmt.Sprintf("LOCK_START=%d", start),
fmt.Sprintf("LOCK_LEN=%d", length),
)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
require.NoError(t, err, "subprocess failed to run")
val, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 16)
require.NoError(t, err, "failed to parse lock type from subprocess")
return int16(val)
}
// --- Test runner ---
func TestPosixFileLocking(t *testing.T) {
framework := NewFuseTestFramework(t, DefaultTestConfig())
defer framework.Cleanup()
require.NoError(t, framework.Setup(DefaultTestConfig()))
t.Run("FlockExclusive", func(t *testing.T) {
testFlockExclusive(t, framework)
})
t.Run("FlockShared", func(t *testing.T) {
testFlockShared(t, framework)
})
t.Run("FlockUpgrade", func(t *testing.T) {
testFlockUpgrade(t, framework)
})
t.Run("FlockReleaseOnClose", func(t *testing.T) {
testFlockReleaseOnClose(t, framework)
})
t.Run("FcntlWriteLockConflict", func(t *testing.T) {
testFcntlWriteLockConflict(t, framework)
})
t.Run("FcntlReadLocksShared", func(t *testing.T) {
testFcntlReadLocksShared(t, framework)
})
t.Run("FcntlGetLk", func(t *testing.T) {
testFcntlGetLk(t, framework)
})
t.Run("FcntlByteRangePartial", func(t *testing.T) {
testFcntlByteRangePartial(t, framework)
})
t.Run("FcntlSetLkwBlocks", func(t *testing.T) {
testFcntlSetLkwBlocks(t, framework)
})
t.Run("FcntlReleaseOnClose", func(t *testing.T) {
testFcntlReleaseOnClose(t, framework)
})
t.Run("ConcurrentLockContention", func(t *testing.T) {
testConcurrentLockContention(t, framework)
})
}
// --- flock() tests (same-process is fine — flock uses per-fd owners) ---
func testFlockExclusive(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "flock_exclusive.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))
f1, err := os.Open(path)
require.NoError(t, err)
defer f1.Close()
f2, err := os.Open(path)
require.NoError(t, err)
defer f2.Close()
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_EX|syscall.LOCK_NB))
err = syscall.Flock(int(f2.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
assert.ErrorIs(t, err, syscall.EWOULDBLOCK, "second exclusive flock should fail")
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_UN))
require.NoError(t, syscall.Flock(int(f2.Fd()), syscall.LOCK_EX|syscall.LOCK_NB))
require.NoError(t, syscall.Flock(int(f2.Fd()), syscall.LOCK_UN))
}
func testFlockShared(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "flock_shared.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))
f1, err := os.Open(path)
require.NoError(t, err)
defer f1.Close()
f2, err := os.Open(path)
require.NoError(t, err)
defer f2.Close()
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_SH|syscall.LOCK_NB))
require.NoError(t, syscall.Flock(int(f2.Fd()), syscall.LOCK_SH|syscall.LOCK_NB))
f3, err := os.Open(path)
require.NoError(t, err)
defer f3.Close()
err = syscall.Flock(int(f3.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
assert.ErrorIs(t, err, syscall.EWOULDBLOCK)
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_UN))
require.NoError(t, syscall.Flock(int(f2.Fd()), syscall.LOCK_UN))
}
func testFlockUpgrade(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "flock_upgrade.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))
f1, err := os.Open(path)
require.NoError(t, err)
defer f1.Close()
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_SH|syscall.LOCK_NB))
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_EX|syscall.LOCK_NB))
f2, err := os.Open(path)
require.NoError(t, err)
defer f2.Close()
err = syscall.Flock(int(f2.Fd()), syscall.LOCK_SH|syscall.LOCK_NB)
assert.ErrorIs(t, err, syscall.EWOULDBLOCK)
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_UN))
}
func testFlockReleaseOnClose(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "flock_close.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))
f1, err := os.Open(path)
require.NoError(t, err)
require.NoError(t, syscall.Flock(int(f1.Fd()), syscall.LOCK_EX|syscall.LOCK_NB))
f1.Close()
f2, err := os.Open(path)
require.NoError(t, err)
defer f2.Close()
require.NoError(t, syscall.Flock(int(f2.Fd()), syscall.LOCK_EX|syscall.LOCK_NB))
require.NoError(t, syscall.Flock(int(f2.Fd()), syscall.LOCK_UN))
}
// --- fcntl() POSIX lock tests (use subprocesses for inter-process semantics) ---
// testFcntlWriteLockConflict: test process holds a write lock, subprocess
// contender is denied, then succeeds after release.
func testFcntlWriteLockConflict(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "fcntl_write.txt")
require.NoError(t, os.WriteFile(path, []byte("0123456789"), 0644))
f1, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
defer f1.Close()
// Test process locks [0, 10).
require.NoError(t, fcntlSetLk(f1, syscall.F_WRLCK, 0, 10))
// Subprocess: overlapping write lock should fail with EAGAIN.
err = tryLockInSubprocess(t, path, "F_WRLCK", 5, 10)
assert.ErrorIs(t, err, syscall.EAGAIN, "overlapping write lock should fail with EAGAIN")
// Unlock, then subprocess should succeed.
require.NoError(t, fcntlSetLk(f1, syscall.F_UNLCK, 0, 10))
err = tryLockInSubprocess(t, path, "F_WRLCK", 5, 10)
assert.NoError(t, err, "lock should succeed after release")
}
// testFcntlReadLocksShared: test process holds a read lock, subprocess read
// lock succeeds (shared), subprocess write lock is denied.
func testFcntlReadLocksShared(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "fcntl_shared.txt")
require.NoError(t, os.WriteFile(path, []byte("0123456789"), 0644))
f1, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
defer f1.Close()
// Test process holds a read lock.
require.NoError(t, fcntlSetLk(f1, syscall.F_RDLCK, 0, 10))
// Subprocess: read lock should succeed (shared).
err = tryLockInSubprocess(t, path, "F_RDLCK", 0, 10)
assert.NoError(t, err, "shared read lock should succeed")
// Subprocess: write lock should fail.
err = tryLockInSubprocess(t, path, "F_WRLCK", 0, 10)
assert.ErrorIs(t, err, syscall.EAGAIN, "write lock should fail with EAGAIN while read lock is held")
require.NoError(t, fcntlSetLk(f1, syscall.F_UNLCK, 0, 10))
}
// testFcntlGetLk: test process holds a write lock, subprocess queries via
// F_GETLK and sees the conflict.
func testFcntlGetLk(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "fcntl_getlk.txt")
require.NoError(t, os.WriteFile(path, []byte("0123456789"), 0644))
f1, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
defer f1.Close()
// Test process takes a write lock on [0, 5).
require.NoError(t, fcntlSetLk(f1, syscall.F_WRLCK, 0, 5))
// Subprocess: F_GETLK on overlapping range should report conflict.
typ := getLkInSubprocess(t, path, "F_WRLCK", 0, 10)
assert.NotEqual(t, int16(syscall.F_UNLCK), typ, "should report a conflict")
// Subprocess: F_GETLK on non-overlapping range should report no conflict.
typ = getLkInSubprocess(t, path, "F_WRLCK", 5, 5)
assert.Equal(t, int16(syscall.F_UNLCK), typ, "no conflict expected on non-overlapping range")
require.NoError(t, fcntlSetLk(f1, syscall.F_UNLCK, 0, 5))
}
// testFcntlByteRangePartial: test process and subprocess lock non-overlapping
// ranges independently; extending into the other's range is denied.
func testFcntlByteRangePartial(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "fcntl_range.txt")
require.NoError(t, os.WriteFile(path, []byte("0123456789ABCDEF"), 0644))
f1, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
defer f1.Close()
// Test process locks [0, 8).
require.NoError(t, fcntlSetLk(f1, syscall.F_WRLCK, 0, 8))
// Subprocess locks [8, 16) — non-overlapping, should succeed.
holder := startLockHolder(t, path, "F_WRLCK", 8, 8)
holder.WaitLocked(t, 5*time.Second)
// Subprocess: extending into test process's range should fail.
err = tryLockInSubprocess(t, path, "F_WRLCK", 4, 8)
assert.ErrorIs(t, err, syscall.EAGAIN, "extending into another process's locked range should fail with EAGAIN")
holder.Release(t)
require.NoError(t, fcntlSetLk(f1, syscall.F_UNLCK, 0, 8))
}
// testFcntlSetLkwBlocks: test process holds exclusive lock, subprocess blocks
// on F_SETLKW, then unblocks when lock is released.
func testFcntlSetLkwBlocks(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "fcntl_setlkw.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))
f1, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
defer f1.Close()
// Test process takes exclusive lock.
require.NoError(t, fcntlSetLk(f1, syscall.F_WRLCK, 0, 0))
// Launch subprocess that will block on F_SETLKW.
blocker := startBlockingLockHolder(t, path, "F_WRLCK", 0, 0)
// Verify it has reached the blocking lock attempt but has not acquired it yet.
blocker.WaitWaiting(t, 5*time.Second)
assert.False(t, blocker.IsLocked(), "F_SETLKW should be blocking")
// Release our lock to unblock the subprocess.
require.NoError(t, fcntlSetLk(f1, syscall.F_UNLCK, 0, 0))
// Subprocess should now acquire and signal "LOCKED".
blocker.WaitLocked(t, 5*time.Second)
blocker.Release(t)
}
// testFcntlReleaseOnClose: subprocess holds a lock then exits (fd closes),
// test process verifies it can now acquire the lock.
func testFcntlReleaseOnClose(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "fcntl_close.txt")
require.NoError(t, os.WriteFile(path, []byte("data"), 0644))
// Subprocess acquires exclusive lock and holds it.
holder := startLockHolder(t, path, "F_WRLCK", 0, 0)
holder.WaitLocked(t, 5*time.Second)
// Another subprocess trying to lock should be denied while holder is active.
err := tryLockInSubprocess(t, path, "F_WRLCK", 0, 0)
assert.ErrorIs(t, err, syscall.EAGAIN, "lock should be held by subprocess")
// Release subprocess → fd closes → lock released.
// The FUSE server may not process the Release immediately, so retry.
holder.Release(t)
f2, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
defer f2.Close()
var lockErr error
for attempt := 0; attempt < 50; attempt++ {
lockErr = fcntlSetLk(f2, syscall.F_WRLCK, 0, 0)
if lockErr == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
require.NoError(t, lockErr, "lock should succeed after subprocess exits")
require.NoError(t, fcntlSetLk(f2, syscall.F_UNLCK, 0, 0))
}
// --- Concurrency test (uses flock which has per-fd owners) ---
// testConcurrentLockContention has multiple goroutines competing for an
// exclusive flock, each appending to the file while holding it.
// Uses non-blocking flock with retry to avoid exhausting the go-fuse server's
// reader goroutines (blocking FUSE SETLKW can starve unlock processing).
func testConcurrentLockContention(t *testing.T, fw *FuseTestFramework) {
path := filepath.Join(fw.GetMountPoint(), "lock_contention.txt")
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
require.NoError(t, err)
require.NoError(t, f.Close())
numWorkers := 8
writesPerWorker := 5
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
var activeHolders atomic.Int32
var nextSlot atomic.Int64
const recordFormat = "worker %02d write %02d\n"
recordSize := len(fmt.Sprintf(recordFormat, 0, 0))
addError := func(err error) {
mu.Lock()
defer mu.Unlock()
errs = append(errs, err)
}
openWithRetry := func(flags int) (*os.File, error) {
var openErr error
for attempt := 0; attempt < 400; attempt++ {
file, err := os.OpenFile(path, flags, 0)
if err == nil {
return file, nil
}
openErr = err
if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOENT) {
return nil, err
}
// Refresh parent directory entries in case the mount cache is stale.
_, _ = os.ReadDir(filepath.Dir(path))
time.Sleep(50 * time.Millisecond)
}
return nil, openErr
}
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
f, err := openWithRetry(os.O_RDWR)
if err != nil {
addError(fmt.Errorf("worker %d open: %v", id, err))
return
}
defer f.Close()
for j := 0; j < writesPerWorker; j++ {
// Non-blocking flock with retry to avoid FUSE server thread starvation.
locked := false
for attempt := 0; attempt < 3000; attempt++ {
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil {
locked = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !locked {
addError(fmt.Errorf("worker %d: failed to acquire flock after retries", id))
return
}
if holders := activeHolders.Add(1); holders != 1 {
activeHolders.Add(-1)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
addError(fmt.Errorf("worker %d: flock overlap detected with %d holders", id, holders))
return
}
time.Sleep(5 * time.Millisecond)
msg := fmt.Sprintf(recordFormat, id, j)
if len(msg) != recordSize {
activeHolders.Add(-1)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
addError(fmt.Errorf("worker %d: unexpected record size %d", id, len(msg)))
return
}
slot := nextSlot.Add(1) - 1
offset := slot * int64(recordSize)
n, err := f.WriteAt([]byte(msg), offset)
if err != nil {
activeHolders.Add(-1)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
addError(fmt.Errorf("worker %d write: %v", id, err))
return
}
if n != len(msg) {
activeHolders.Add(-1)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
addError(fmt.Errorf("worker %d short write: wrote %d of %d bytes", id, n, len(msg)))
return
}
activeHolders.Add(-1)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}
}(i)
}
wg.Wait()
if runtime.GOOS == "darwin" {
for _, err := range errs {
if err != nil && strings.Contains(err.Error(), "no such file or directory") {
t.Skip("lock contention file disappeared on darwin FUSE; skipping flaky check")
}
}
}
require.Empty(t, errs, "concurrent lock contention errors: %v", errs)
flush := func() {
verify, err := openWithRetry(os.O_RDWR)
if err != nil && runtime.GOOS == "darwin" && strings.Contains(err.Error(), "no such file or directory") {
t.Skip("lock contention file disappeared on darwin FUSE; skipping flaky check")
}
require.NoError(t, err)
defer verify.Close()
require.NoError(t, verify.Sync())
}
flush()
expectedLines := numWorkers * writesPerWorker
expectedBytes := expectedLines * recordSize
var data []byte
require.Eventually(t, func() bool {
verify, err := openWithRetry(os.O_RDONLY)
if err != nil {
if runtime.GOOS == "darwin" && strings.Contains(err.Error(), "no such file or directory") {
t.Skip("lock contention file disappeared on darwin FUSE; skipping flaky check")
}
return false
}
defer verify.Close()
data, err = io.ReadAll(verify)
if err != nil {
return false
}
return len(data) == expectedBytes
}, 15*time.Second, 100*time.Millisecond, "file should eventually contain exactly %d records from all workers", expectedLines)
actualLines := bytes.Count(data, []byte("\n"))
assert.Equal(t, expectedLines, actualLines,
"file should contain exactly %d lines from all workers", expectedLines)
}
+6 -4
View File
@@ -27,17 +27,19 @@ func TestOffsetManagement(t *testing.T) {
t.Logf("Running offset management tests with mock backend - offsets are in-memory only")
}
topic := testutil.GenerateUniqueTopicName("offset-management")
topic1 := testutil.GenerateUniqueTopicName("offset-management")
topic2 := testutil.GenerateUniqueTopicName("offset-resumption")
groupID := testutil.GenerateUniqueGroupID("offset-test-group")
gateway.AddTestTopic(topic)
gateway.AddTestTopic(topic1)
gateway.AddTestTopic(topic2)
t.Run("BasicOffsetCommitFetch", func(t *testing.T) {
testBasicOffsetCommitFetch(t, addr, topic, groupID)
testBasicOffsetCommitFetch(t, addr, topic1, groupID)
})
t.Run("ConsumerGroupResumption", func(t *testing.T) {
testConsumerGroupResumption(t, addr, topic, groupID+"2")
testConsumerGroupResumption(t, addr, topic2, groupID+"2")
})
}
+141 -4
View File
@@ -7,6 +7,14 @@ SEAWEEDFS_S3_ENDPOINT ?= http://127.0.0.1:8333
TEST_TIMEOUT ?= 5m
DOCKER_COMPOSE ?= docker-compose
# CI configuration (build weed from source, run natively)
SEAWEEDFS_BINARY ?= weed
S3_PORT ?= 8333
ACCESS_KEY ?= some_access_key1
SECRET_KEY ?= some_secret_key1
TEST_DIR := $(shell pwd)
SEAWEEDFS_ROOT := $(shell cd ../.. && pwd)
# Colors for output
BLUE := \033[36m
GREEN := \033[32m
@@ -14,7 +22,10 @@ YELLOW := \033[33m
RED := \033[31m
NC := \033[0m # No Color
.PHONY: help setup test test-unit test-integration test-e2e clean logs status
.PHONY: help setup test test-unit test-integration test-e2e clean logs status \
build-weed check-binary start-openbao-ci stop-openbao-ci \
start-seaweedfs-ci stop-seaweedfs-ci clean-ci \
test-provider-ci test-s3-kms-ci
help: ## Show this help message
@echo "$(BLUE)SeaweedFS KMS Integration Testing$(NC)"
@@ -124,16 +135,142 @@ check-env: ## Check test environment setup
@echo "TEST_TIMEOUT: $(TEST_TIMEOUT)"
@make install-deps
# CI targets
ci-test: ## Run tests in CI environment
# CI targets (docker-compose based)
ci-test: ## Run tests in CI environment (docker-compose)
@echo "$(YELLOW)Running CI tests...$(NC)"
@make setup
@make test-unit
@make test-integration
@make clean
ci-e2e: ## Run end-to-end tests in CI
ci-e2e: ## Run end-to-end tests in CI (docker-compose)
@echo "$(YELLOW)Running CI end-to-end tests...$(NC)"
@make setup-seaweedfs
@make test-e2e
@make clean
####################################################
# GitHub Actions CI targets (build weed from source)
####################################################
build-weed: ## Build SeaweedFS binary from source
@echo "Building SeaweedFS binary..."
@cd $(SEAWEEDFS_ROOT)/weed && go install -buildvcs=false
@echo "$(GREEN)SeaweedFS binary built successfully$(NC)"
check-binary:
@if ! command -v $(SEAWEEDFS_BINARY) > /dev/null 2>&1; then \
echo "$(RED)Error: SeaweedFS binary '$(SEAWEEDFS_BINARY)' not found in PATH$(NC)"; \
exit 1; \
fi
start-openbao-ci: ## Start OpenBao via Docker for CI
@echo "Starting OpenBao for CI..."
@docker rm -f openbao-ci 2>/dev/null || true
@docker run -d --name openbao-ci \
-p 8200:8200 \
-e BAO_DEV_ROOT_TOKEN_ID=$(OPENBAO_TOKEN) \
-e BAO_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
ghcr.io/openbao/openbao:latest \
bao server -dev -dev-root-token-id=$(OPENBAO_TOKEN) -dev-listen-address=0.0.0.0:8200
@echo "Waiting for OpenBao to be ready..."
@for i in $$(seq 1 30); do \
if curl -s $(OPENBAO_ADDR)/v1/sys/health > /dev/null 2>&1; then \
echo "$(GREEN)OpenBao is ready$(NC)"; \
break; \
fi; \
if [ $$i -eq 30 ]; then \
echo "$(RED)Timeout waiting for OpenBao$(NC)"; \
docker logs openbao-ci 2>&1 | tail -20; \
exit 1; \
fi; \
sleep 2; \
done
@chmod +x setup_openbao.sh
@OPENBAO_ADDR=$(OPENBAO_ADDR) OPENBAO_TOKEN=$(OPENBAO_TOKEN) ./setup_openbao.sh
stop-openbao-ci: ## Stop OpenBao CI container
@echo "Stopping OpenBao..."
@docker stop openbao-ci 2>/dev/null || true
@docker rm openbao-ci 2>/dev/null || true
start-seaweedfs-ci: check-binary ## Start weed mini with OpenBao KMS config
@echo "Starting SeaweedFS with OpenBao KMS..."
@mkdir -p /tmp/seaweedfs-test-kms
@rm -f /tmp/seaweedfs-kms-*.log || true
@sed -e 's/ACCESS_KEY_PLACEHOLDER/$(ACCESS_KEY)/g' \
-e 's/SECRET_KEY_PLACEHOLDER/$(SECRET_KEY)/g' \
-e 's|OPENBAO_ADDR_PLACEHOLDER|$(OPENBAO_ADDR)|g' \
-e 's/OPENBAO_TOKEN_PLACEHOLDER/$(OPENBAO_TOKEN)/g' \
s3-config-openbao-template.json > /tmp/seaweedfs-kms-s3.json
@AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) GLOG_v=4 $(SEAWEEDFS_BINARY) mini \
-dir=/tmp/seaweedfs-test-kms \
-s3.port=$(S3_PORT) \
-s3.config=/tmp/seaweedfs-kms-s3.json \
-ip=127.0.0.1 \
> /tmp/seaweedfs-kms-mini.log 2>&1 & echo $$! > /tmp/weed-kms-mini.pid
@echo "Checking S3 service is ready..."
@for i in $$(seq 1 30); do \
if curl -s http://127.0.0.1:$(S3_PORT) > /dev/null 2>&1; then \
echo "$(GREEN)S3 service is ready$(NC)"; \
break; \
fi; \
if [ $$i -eq 30 ]; then \
echo "$(RED)Timeout waiting for S3 service$(NC)"; \
tail -20 /tmp/seaweedfs-kms-mini.log 2>/dev/null || true; \
exit 1; \
fi; \
sleep 1; \
done
stop-seaweedfs-ci: ## Stop weed mini (CI-safe)
@echo "Stopping SeaweedFS..."
@if [ -f /tmp/weed-kms-mini.pid ]; then \
kill $$(cat /tmp/weed-kms-mini.pid) 2>/dev/null || true; \
rm -f /tmp/weed-kms-mini.pid; \
fi
@if command -v lsof >/dev/null 2>&1; then \
lsof -ti :$(S3_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \
fi
@sleep 2
clean-ci: ## Clean up CI test artifacts
@rm -rf /tmp/seaweedfs-test-kms
@rm -f /tmp/seaweedfs-kms-*.log /tmp/seaweedfs-kms-s3.json /tmp/weed-kms-mini.pid
# Run KMS provider Go integration tests (GitHub Actions compatible)
test-provider-ci: build-weed ## Run KMS provider tests with OpenBao in CI
@echo "$(YELLOW)Starting KMS provider integration tests...$(NC)"
@if $(MAKE) start-openbao-ci > /tmp/openbao-ci-setup.log 2>&1; then \
echo "$(GREEN)OpenBao started and configured$(NC)"; \
trap '$(MAKE) -C $(TEST_DIR) stop-openbao-ci || true' EXIT; \
cd $(SEAWEEDFS_ROOT) && go test -v -timeout=$(TEST_TIMEOUT) ./test/kms/... || exit 1; \
echo "$(GREEN)KMS provider tests completed successfully$(NC)"; \
else \
echo "$(RED)Failed to start OpenBao$(NC)"; \
cat /tmp/openbao-ci-setup.log 2>/dev/null || true; \
exit 1; \
fi
# Run S3 KMS end-to-end tests (GitHub Actions compatible)
test-s3-kms-ci: build-weed ## Run S3 KMS e2e tests with weed mini + OpenBao in CI
@echo "$(YELLOW)Starting S3 KMS end-to-end tests...$(NC)"
@if $(MAKE) start-openbao-ci > /tmp/openbao-ci-setup.log 2>&1; then \
echo "$(GREEN)OpenBao started and configured$(NC)"; \
trap '$(MAKE) -C $(TEST_DIR) stop-seaweedfs-ci || true; $(MAKE) -C $(TEST_DIR) stop-openbao-ci || true; $(MAKE) -C $(TEST_DIR) clean-ci || true' EXIT; \
if $(MAKE) start-seaweedfs-ci > /tmp/weed-kms-ci-setup.log 2>&1; then \
echo "$(GREEN)SeaweedFS started with OpenBao KMS$(NC)"; \
sleep 3; \
chmod +x test_s3_kms.sh; \
SEAWEEDFS_S3_ENDPOINT=http://127.0.0.1:$(S3_PORT) ACCESS_KEY=$(ACCESS_KEY) SECRET_KEY=$(SECRET_KEY) ./test_s3_kms.sh || exit 1; \
echo "$(GREEN)S3 KMS e2e tests completed successfully$(NC)"; \
else \
echo "$(RED)Failed to start SeaweedFS$(NC)"; \
cat /tmp/weed-kms-ci-setup.log 2>/dev/null || true; \
exit 1; \
fi; \
else \
echo "$(RED)Failed to start OpenBao$(NC)"; \
cat /tmp/openbao-ci-setup.log 2>/dev/null || true; \
exit 1; \
fi
+27
View File
@@ -0,0 +1,27 @@
{
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "ACCESS_KEY_PLACEHOLDER",
"secretKey": "SECRET_KEY_PLACEHOLDER"
}
],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
],
"kms": {
"default_provider": "openbao",
"providers": {
"openbao": {
"type": "openbao",
"address": "OPENBAO_ADDR_PLACEHOLDER",
"token": "OPENBAO_TOKEN_PLACEHOLDER",
"transit_path": "transit",
"cache_enabled": true,
"cache_ttl": "20m"
}
}
}
}
@@ -3,6 +3,7 @@ package metadata_subscribe
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"mime/multipart"
@@ -27,6 +28,8 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
const slowConsumerMetadataPayloadSize = 4096
// TestMetadataSubscribeBasic tests basic metadata subscription functionality
func TestMetadataSubscribeBasic(t *testing.T) {
if testing.Short() {
@@ -680,6 +683,108 @@ func TestMetadataSubscribeMillionUpdates(t *testing.T) {
})
}
func TestMetadataSubscribeSlowConsumerKeepsProgressing(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
testDir, err := os.MkdirTemp("", "seaweedfs_slow_consumer_test_")
require.NoError(t, err)
defer os.RemoveAll(testDir)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cluster, err := startSeaweedFSCluster(ctx, testDir)
require.NoError(t, err)
defer cluster.Stop()
require.NoError(t, waitForHTTPServer("http://127.0.0.1:9333", 30*time.Second))
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8080", 30*time.Second))
require.NoError(t, waitForHTTPServer("http://127.0.0.1:8888", 30*time.Second))
t.Logf("Cluster started for slow consumer regression test")
t.Run("single_filer_slow_consumer", func(t *testing.T) {
var receivedCount int64
phaseOneEntries := 6000
phaseTwoEntries := 14000
totalEntries := phaseOneEntries + phaseTwoEntries
minExpected := int64(12000)
errChan := make(chan error, 1)
subCtx, subCancel := context.WithCancel(ctx)
defer subCancel()
go func() {
err := followMetadataSlowly(
subCtx,
"127.0.0.1:8888",
"/slow-consumer/",
time.Now().Add(-5*time.Second).UnixNano(),
time.Millisecond,
func(resp *filer_pb.SubscribeMetadataResponse) {
if resp.GetEventNotification() == nil {
return
}
entry := resp.GetEventNotification().GetNewEntry()
if entry == nil || entry.IsDirectory {
return
}
atomic.AddInt64(&receivedCount, 1)
},
)
if err != nil && !errors.Is(err, context.Canceled) {
errChan <- err
}
}()
time.Sleep(2 * time.Second)
payload := bytes.Repeat([]byte("x"), slowConsumerMetadataPayloadSize)
startTime := time.Now()
require.NoError(t, createMetadataEntries(ctx, "127.0.0.1:8888", 0, phaseOneEntries, payload))
t.Logf("Created phase 1 with %d entries in %v", phaseOneEntries, time.Since(startTime))
time.Sleep(2 * time.Second)
require.NoError(t, createMetadataEntries(ctx, "127.0.0.1:8888", phaseOneEntries, phaseTwoEntries, payload))
t.Logf("Created phase 2 with %d entries", phaseTwoEntries)
checkTicker := time.NewTicker(2 * time.Second)
defer checkTicker.Stop()
deadline := time.NewTimer(45 * time.Second)
defer deadline.Stop()
lastReceived := int64(-1)
stableChecks := 0
for {
select {
case err := <-errChan:
t.Fatalf("slow consumer subscription error: %v", err)
case <-deadline.C:
t.Fatalf("timed out waiting for slow consumer progress, received %d/%d", atomic.LoadInt64(&receivedCount), totalEntries)
case <-checkTicker.C:
received := atomic.LoadInt64(&receivedCount)
t.Logf("Slow consumer progress: %d/%d", received, totalEntries)
if received >= minExpected {
return
}
if received == lastReceived {
stableChecks++
if stableChecks >= 4 {
t.Fatalf("slow consumer stalled at %d/%d after writes completed", received, totalEntries)
}
} else {
stableChecks = 0
}
lastReceived = received
}
}
})
}
// Helper types and functions
type TestCluster struct {
@@ -915,3 +1020,106 @@ func subscribeToMetadataWithOptions(ctx context.Context, filerGrpcAddress, pathP
}
})
}
func followMetadataSlowly(
ctx context.Context,
filerGrpcAddress, pathPrefix string,
sinceNs int64,
delay time.Duration,
onEvent func(resp *filer_pb.SubscribeMetadataResponse),
) error {
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
if grpcDialOption == nil {
grpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
}
option := &pb.MetadataFollowOption{
ClientName: "slow_consumer_test",
ClientId: util.RandomInt32(),
ClientEpoch: int32(time.Now().Unix()),
PathPrefix: pathPrefix,
StartTsNs: sinceNs,
EventErrorType: pb.DontLogError,
}
return pb.FollowMetadata(pb.ServerAddress(filerGrpcAddress), grpcDialOption, option, func(resp *filer_pb.SubscribeMetadataResponse) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
onEvent(resp)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
return nil
}
})
}
func createMetadataEntries(ctx context.Context, filerGrpcAddress string, startIndex, total int, payload []byte) error {
const workers = 10
grpcDialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
errCh := make(chan error, workers)
var wg sync.WaitGroup
for workerID := 0; workerID < workers; workerID++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
err := pb.WithFilerClient(false, 0, pb.ServerAddress(filerGrpcAddress), grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
for idx := startIndex + workerID; idx < startIndex+total; idx += workers {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
dir := fmt.Sprintf("/slow-consumer/bucket-%02d", idx%6)
name := fmt.Sprintf("entry-%05d", idx)
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: &filer_pb.Entry{
Name: name,
IsDirectory: false,
Attributes: &filer_pb.FuseAttributes{
FileSize: uint64(len(payload)),
Mtime: time.Now().Unix(),
FileMode: 0644,
Uid: 1000,
Gid: 1000,
},
Extended: map[string][]byte{
"payload": payload,
},
},
})
if err != nil {
return err
}
}
return nil
})
if err != nil {
errCh <- err
}
}(workerID)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
+6 -12
View File
@@ -590,17 +590,14 @@ func TestCORSCaching(t *testing.T) {
Bucket: aws.String(bucketName),
CORSConfiguration: corsConfig1,
})
assert.NoError(t, err, "Should be able to put initial CORS configuration")
// Wait for metadata subscription to update cache
time.Sleep(50 * time.Millisecond)
require.NoError(t, err, "Should be able to put initial CORS configuration")
// Get the configuration
getResp1, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
Bucket: aws.String(bucketName),
})
assert.NoError(t, err, "Should be able to get initial CORS configuration")
assert.Len(t, getResp1.CORSRules, 1, "Should have one CORS rule")
require.NoError(t, err, "Should be able to get initial CORS configuration")
require.Len(t, getResp1.CORSRules, 1, "Should have one CORS rule")
// Update the configuration
corsConfig2 := &types.CORSConfiguration{
@@ -618,17 +615,14 @@ func TestCORSCaching(t *testing.T) {
Bucket: aws.String(bucketName),
CORSConfiguration: corsConfig2,
})
assert.NoError(t, err, "Should be able to update CORS configuration")
// Wait for metadata subscription to update cache
time.Sleep(50 * time.Millisecond)
require.NoError(t, err, "Should be able to update CORS configuration")
// Get the updated configuration (should reflect the changes)
getResp2, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
Bucket: aws.String(bucketName),
})
assert.NoError(t, err, "Should be able to get updated CORS configuration")
assert.Len(t, getResp2.CORSRules, 1, "Should have one CORS rule")
require.NoError(t, err, "Should be able to get updated CORS configuration")
require.Len(t, getResp2.CORSRules, 1, "Should have one CORS rule")
rule := getResp2.CORSRules[0]
assert.Equal(t, []string{"Content-Type"}, rule.AllowedHeaders, "Should have updated headers")
+45
View File
@@ -99,6 +99,10 @@ func TestS3Integration(t *testing.T) {
testDeleteObject(t, cluster)
})
t.Run("DeleteDirectoryMarkerWithChildren", func(t *testing.T) {
testDeleteDirectoryMarkerWithChildren(t, cluster)
})
t.Run("DeleteBucket", func(t *testing.T) {
testDeleteBucket(t, cluster)
})
@@ -754,6 +758,47 @@ func testDeleteObject(t *testing.T, cluster *TestCluster) {
t.Logf("✓ Deleted object: %s/%s", bucketName, objectKey)
}
func testDeleteDirectoryMarkerWithChildren(t *testing.T, cluster *TestCluster) {
bucketName := createTestBucket(t, cluster, "test-delete-dir-marker-")
childKey := "test-content/file1.txt"
directoryMarkerKey := "test-content/"
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(childKey),
Body: bytes.NewReader([]byte("child")),
})
require.NoError(t, err)
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(directoryMarkerKey),
Body: bytes.NewReader(nil),
ContentType: aws.String("application/octet-stream"),
})
require.NoError(t, err)
_, err = cluster.s3Client.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(directoryMarkerKey),
})
require.NoError(t, err, "Deleting a directory marker should succeed even when children exist")
listResp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
Prefix: aws.String("test-content/"),
})
require.NoError(t, err)
foundKeys := make(map[string]bool)
for _, obj := range listResp.Contents {
foundKeys[aws.StringValue(obj.Key)] = true
}
assert.True(t, foundKeys[childKey], "Child object should remain after deleting the directory marker")
assert.False(t, foundKeys[directoryMarkerKey], "Directory marker should no longer be listed after deletion")
}
func testDeleteBucket(t *testing.T, cluster *TestCluster) {
bucketName := "test-delete-bucket-" + randomString(8)
@@ -5,6 +5,8 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"time"
@@ -45,6 +47,39 @@ func UploadBytes(t testing.TB, client *http.Client, volumeURL, fid string, data
return DoRequest(t, client, req)
}
// CorruptIndexFile appends garbage bytes to a volume's .idx file on disk so
// that CheckIndexFile detects a size mismatch during scrub.
func CorruptIndexFile(t testing.TB, baseDir string, volumeID uint32) {
t.Helper()
idxPath := filepath.Join(baseDir, "volume", fmt.Sprintf("%d.idx", volumeID))
f, err := os.OpenFile(idxPath, os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
t.Fatalf("open idx file for corruption: %v", err)
}
defer f.Close()
if _, err := f.Write([]byte{0xDE, 0xAD}); err != nil {
t.Fatalf("corrupt idx file: %v", err)
}
}
// EnableMaintenanceMode puts the volume server into maintenance mode.
func EnableMaintenanceMode(t testing.TB, ctx context.Context, client volume_server_pb.VolumeServerClient) {
t.Helper()
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: stateResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
}
func ReadBytes(t testing.TB, client *http.Client, volumeURL, fid string) *http.Response {
t.Helper()
@@ -97,9 +97,13 @@ func TestVolumeMarkReadonlyWritableErrorPaths(t *testing.T) {
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(75)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// non-existent volumes should return "not found"
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 98771, Persist: true})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeMarkReadonly missing-volume error mismatch: %v", err)
@@ -110,29 +114,31 @@ func TestVolumeMarkReadonlyWritableErrorPaths(t *testing.T) {
t.Fatalf("VolumeMarkWritable missing-volume error mismatch: %v", err)
}
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: stateResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
// enter maintenance mode
framework.EnableMaintenanceMode(t, ctx, grpcClient)
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 1, Persist: true})
// existing volume in maintenance mode should return "maintenance mode" error
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: volumeID, Persist: true})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeMarkReadonly maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: 1})
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: volumeID})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeMarkWritable maintenance error mismatch: %v", err)
}
// non-existent volume in maintenance mode should still return "not found"
// (volume lookup happens before maintenance check)
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 98773, Persist: true})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeMarkReadonly missing-volume in maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: 98774})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeMarkWritable missing-volume in maintenance error mismatch: %v", err)
}
}
func TestDeleteCollectionRemovesVolumeAndIsIdempotent(t *testing.T) {
+146
View File
@@ -385,3 +385,149 @@ func TestQueryCookieMismatchReturnsEOFNoResults(t *testing.T) {
t.Fatalf("Query cookie mismatch expected EOF with no streamed records, got: %v", err)
}
}
func TestScrubVolumeMarkBrokenReadonlyHealthyVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(76)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, 1, 1), []byte("healthy data"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeID},
Mode: volume_server_pb.VolumeScrubMode_INDEX,
MarkBrokenVolumesReadonly: true,
})
if err != nil {
t.Fatalf("ScrubVolume with MarkBrokenVolumesReadonly on healthy volume failed: %v", err)
}
if len(resp.GetBrokenVolumeIds()) != 0 {
t.Fatalf("expected no broken volumes, got %v", resp.GetBrokenVolumeIds())
}
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus failed: %v", err)
}
if statusResp.GetIsReadOnly() {
t.Fatalf("healthy volume should not be read-only after scrub with MarkBrokenVolumesReadonly")
}
}
func TestScrubVolumeMarkBrokenReadonlyCorruptVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(77)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, 1, 1), []byte("test data"))
framework.CorruptIndexFile(t, clusterHarness.BaseDir(), volumeID)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// scrub without the flag: broken volume reported but not marked read-only
resp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeID},
Mode: volume_server_pb.VolumeScrubMode_INDEX,
})
if err != nil {
t.Fatalf("ScrubVolume without flag failed: %v", err)
}
if len(resp.GetBrokenVolumeIds()) == 0 {
t.Fatalf("expected broken volume after corruption")
}
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after scrub without flag failed: %v", err)
}
if statusResp.GetIsReadOnly() {
t.Fatalf("volume should not be read-only when MarkBrokenVolumesReadonly is false")
}
// scrub with the flag: broken volume should now be marked read-only
resp, err = grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeID},
Mode: volume_server_pb.VolumeScrubMode_INDEX,
MarkBrokenVolumesReadonly: true,
})
if err != nil {
t.Fatalf("ScrubVolume with MarkBrokenVolumesReadonly failed: %v", err)
}
if len(resp.GetBrokenVolumeIds()) == 0 {
t.Fatalf("expected broken volume after corruption with flag")
}
found := false
for _, d := range resp.GetDetails() {
if strings.Contains(d, "is now read-only") {
found = true
break
}
}
if !found {
t.Fatalf("expected 'is now read-only' in details, got: %v", resp.GetDetails())
}
statusResp, err = grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after MarkBrokenVolumesReadonly scrub failed: %v", err)
}
if !statusResp.GetIsReadOnly() {
t.Fatalf("broken volume should be read-only after MarkBrokenVolumesReadonly scrub")
}
}
func TestScrubVolumeMarkBrokenReadonlyInMaintenanceMode(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(78)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, 1, 1), []byte("test data"))
framework.CorruptIndexFile(t, clusterHarness.BaseDir(), volumeID)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
framework.EnableMaintenanceMode(t, ctx, grpcClient)
// scrub with the flag in maintenance mode: makeVolumeReadonly should fail
// and ScrubVolume should propagate the error
_, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeID},
Mode: volume_server_pb.VolumeScrubMode_INDEX,
MarkBrokenVolumesReadonly: true,
})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("ScrubVolume with MarkBrokenVolumesReadonly in maintenance mode error mismatch: %v", err)
}
}
+5 -4
View File
@@ -48,10 +48,11 @@ type ObjectStoreUser struct {
}
type ObjectStoreUsersData struct {
Username string `json:"username"`
Users []ObjectStoreUser `json:"users"`
TotalUsers int `json:"total_users"`
LastUpdated time.Time `json:"last_updated"`
Username string `json:"username"`
Users []ObjectStoreUser `json:"users"`
TotalUsers int `json:"total_users"`
HasAnonymousUser bool `json:"has_anonymous_user"`
LastUpdated time.Time `json:"last_updated"`
}
// User management request structures
+13 -4
View File
@@ -311,10 +311,19 @@ func (h *UserHandlers) getObjectStoreUsersData(r *http.Request) dash.ObjectStore
}
}
hasAnonymous := false
for _, u := range users {
if u.Username == "anonymous" {
hasAnonymous = true
break
}
}
return dash.ObjectStoreUsersData{
Username: username,
Users: users,
TotalUsers: len(users),
LastUpdated: time.Now(),
Username: username,
Users: users,
TotalUsers: len(users),
HasAnonymousUser: hasAnonymous,
LastUpdated: time.Now(),
}
}
+37 -5
View File
@@ -141,10 +141,12 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
data-action="edit-user" data-username={ user.Username }>
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
data-action="manage-access-keys" data-username={ user.Username }>
<i class="fas fa-key"></i>
</button>
if user.Username != "anonymous" {
<button type="button" class="btn btn-outline-secondary"
data-action="manage-access-keys" data-username={ user.Username }>
<i class="fas fa-key"></i>
</button>
}
<button type="button" class="btn btn-outline-danger"
data-action="delete-user" data-username={ user.Username }>
<i class="fas fa-trash"></i>
@@ -197,7 +199,17 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
<form id="createUserForm">
<div class="mb-3">
<label for="username" class="form-label">Username *</label>
<input type="text" class="form-control" id="username" name="username" required>
if !data.HasAnonymousUser {
<div class="input-group">
<input type="text" class="form-control" id="username" name="username" required>
<div class="input-group-text">
<input class="form-check-input mt-0 me-1" type="checkbox" id="anonymousCheck" onchange="toggleAnonymousUser()">
<label class="form-check-label small" for="anonymousCheck">Anonymous</label>
</div>
</div>
} else {
<input type="text" class="form-control" id="username" name="username" required>
}
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
@@ -669,6 +681,24 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
}
}
// Toggle anonymous user checkbox
function toggleAnonymousUser() {
const checkbox = document.getElementById('anonymousCheck');
const usernameInput = document.getElementById('username');
const generateKey = document.getElementById('generateKey');
if (checkbox.checked) {
usernameInput.value = 'anonymous';
usernameInput.readOnly = true;
generateKey.checked = false;
generateKey.disabled = true;
} else {
usernameInput.value = '';
usernameInput.readOnly = false;
generateKey.disabled = false;
generateKey.checked = true;
}
}
// Populate bucket selection dropdowns
function populateBucketSelections() {
const createSelect = document.getElementById('selectedBuckets');
@@ -1055,6 +1085,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));
modal.hide();
form.reset();
document.getElementById('username').readOnly = false;
document.getElementById('generateKey').disabled = false;
setTimeout(() => window.location.reload(), 1000);
} else {
const error = await response.json();
File diff suppressed because one or more lines are too long
+51 -33
View File
@@ -3,6 +3,7 @@ package command
import (
"errors"
"fmt"
nethttp "net/http"
"regexp"
"strings"
"time"
@@ -14,26 +15,30 @@ import (
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/http"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"google.golang.org/grpc"
)
type FilerBackupOptions struct {
isActivePassive *bool
filer *string
path *string
excludePaths *string
excludeFileName *string
debug *bool
proxyByFiler *bool
doDeleteFiles *bool
disableErrorRetry *bool
ignore404Error *bool
timeAgo *time.Duration
retentionDays *int
isActivePassive *bool
filer *string
path *string
excludePaths *string
excludeFileName *string // deprecated: use excludeFileNames
excludeFileNames *string
excludePathPatterns *string
debug *bool
proxyByFiler *bool
doDeleteFiles *bool
disableErrorRetry *bool
ignore404Error *bool
timeAgo *time.Duration
retentionDays *int
}
var (
filerBackupOptions FilerBackupOptions
filerBackupOptions FilerBackupOptions
ignorable404ErrString = fmt.Sprintf("%d %s: %s", nethttp.StatusNotFound, nethttp.StatusText(nethttp.StatusNotFound), http.ErrNotFound.Error())
)
func init() {
@@ -41,7 +46,9 @@ func init() {
filerBackupOptions.filer = cmdFilerBackup.Flag.String("filer", "localhost:8888", "filer of one SeaweedFS cluster")
filerBackupOptions.path = cmdFilerBackup.Flag.String("filerPath", "/", "directory to sync on filer")
filerBackupOptions.excludePaths = cmdFilerBackup.Flag.String("filerExcludePaths", "", "exclude directories to sync on filer")
filerBackupOptions.excludeFileName = cmdFilerBackup.Flag.String("filerExcludeFileName", "", "exclude file names that match the regexp to sync on filer")
filerBackupOptions.excludeFileName = cmdFilerBackup.Flag.String("filerExcludeFileName", "", "[DEPRECATED: use -filerExcludeFileNames] exclude file names that match the regexp")
filerBackupOptions.excludeFileNames = cmdFilerBackup.Flag.String("filerExcludeFileNames", "", "comma-separated wildcard patterns to exclude file names, e.g., \"*.tmp,._*\"")
filerBackupOptions.excludePathPatterns = cmdFilerBackup.Flag.String("filerExcludePathPatterns", "", "comma-separated wildcard patterns to exclude paths where any component matches, e.g., \".snapshot,temp*\"")
filerBackupOptions.proxyByFiler = cmdFilerBackup.Flag.Bool("filerProxy", false, "read and write file chunks by filer instead of volume servers")
filerBackupOptions.doDeleteFiles = cmdFilerBackup.Flag.Bool("doDeleteFiles", false, "delete files on the destination")
filerBackupOptions.debug = cmdFilerBackup.Flag.Bool("debug", false, "debug mode to print out received files")
@@ -70,6 +77,15 @@ func runFilerBackup(cmd *Command, args []string) bool {
util.LoadSecurityConfiguration()
util.LoadConfiguration("replication", true)
// Compile exclude patterns once before the retry loop — these are
// configuration errors and must not be retried.
reExcludeFileName, err := compileExcludePattern(*filerBackupOptions.excludeFileName, "exclude file name")
if err != nil {
glog.Fatalf("invalid -filerExcludeFileName: %v", err)
}
excludeFileNames := wildcard.CompileWildcardMatchers(*filerBackupOptions.excludeFileNames)
excludePathPatterns := wildcard.CompileWildcardMatchers(*filerBackupOptions.excludePathPatterns)
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
clientId := util.RandomInt32()
@@ -77,7 +93,7 @@ func runFilerBackup(cmd *Command, args []string) bool {
for {
clientEpoch++
err := doFilerBackup(grpcDialOption, &filerBackupOptions, clientId, clientEpoch)
err := doFilerBackup(grpcDialOption, &filerBackupOptions, reExcludeFileName, excludeFileNames, excludePathPatterns, clientId, clientEpoch)
if err != nil {
glog.Errorf("backup from %s: %v", *filerBackupOptions.filer, err)
time.Sleep(1747 * time.Millisecond)
@@ -89,7 +105,7 @@ const (
BackupKeyPrefix = "backup."
)
func doFilerBackup(grpcDialOption grpc.DialOption, backupOption *FilerBackupOptions, clientId int32, clientEpoch int32) error {
func doFilerBackup(grpcDialOption grpc.DialOption, backupOption *FilerBackupOptions, reExcludeFileName *regexp.Regexp, excludeFileNames []*wildcard.WildcardMatcher, excludePathPatterns []*wildcard.WildcardMatcher, clientId int32, clientEpoch int32) error {
// find data sink
dataSink := findSink(util.GetViper())
@@ -100,13 +116,6 @@ func doFilerBackup(grpcDialOption grpc.DialOption, backupOption *FilerBackupOpti
sourceFiler := pb.ServerAddress(*backupOption.filer)
sourcePath := *backupOption.path
excludePaths := util.StringSplit(*backupOption.excludePaths, ",")
var reExcludeFileName *regexp.Regexp
if *backupOption.excludeFileName != "" {
var err error
if reExcludeFileName, err = regexp.Compile(*backupOption.excludeFileName); err != nil {
return fmt.Errorf("error compile regexp %v for exclude file name: %+v", *backupOption.excludeFileName, err)
}
}
timeAgo := *backupOption.timeAgo
targetPath := dataSink.GetSinkToDirectory()
debug := *backupOption.debug
@@ -138,27 +147,20 @@ func doFilerBackup(grpcDialOption grpc.DialOption, backupOption *FilerBackupOpti
var processEventFn func(*filer_pb.SubscribeMetadataResponse) error
if *backupOption.ignore404Error {
processEventFnGenerated := genProcessFunction(sourcePath, targetPath, excludePaths, reExcludeFileName, dataSink, *backupOption.doDeleteFiles, debug)
processEventFnGenerated := genProcessFunction(sourcePath, targetPath, excludePaths, reExcludeFileName, excludeFileNames, excludePathPatterns, dataSink, *backupOption.doDeleteFiles, debug)
processEventFn = func(resp *filer_pb.SubscribeMetadataResponse) error {
err := processEventFnGenerated(resp)
if err == nil {
return nil
}
// ignore HTTP 404 from remote reads
if errors.Is(err, http.ErrNotFound) {
if isIgnorable404(err) {
glog.V(0).Infof("got 404 error for %s, ignore it: %s", getSourceKey(resp), err.Error())
return nil
}
// also ignore missing volume/lookup errors coming from LookupFileId or vid map
errStr := err.Error()
if strings.Contains(errStr, "LookupFileId") || (strings.Contains(errStr, "volume id") && strings.Contains(errStr, "not found")) {
glog.V(0).Infof("got missing-volume error for %s, ignore it: %s", getSourceKey(resp), errStr)
return nil
}
return err
}
} else {
processEventFn = genProcessFunction(sourcePath, targetPath, excludePaths, reExcludeFileName, dataSink, *backupOption.doDeleteFiles, debug)
processEventFn = genProcessFunction(sourcePath, targetPath, excludePaths, reExcludeFileName, excludeFileNames, excludePathPatterns, dataSink, *backupOption.doDeleteFiles, debug)
}
processEventFnWithOffset := pb.AddOffsetFunc(processEventFn, 3*time.Second, func(counter int64, lastTsNs int64) error {
@@ -218,3 +220,19 @@ func getSourceKey(resp *filer_pb.SubscribeMetadataResponse) string {
}
return ""
}
// isIgnorable404 returns true if the error represents a 404/not-found condition
// that should be silently ignored during backup. This covers:
// - errors wrapping http.ErrNotFound (direct volume server 404 via non-S3 sinks)
// - errors containing the "404 Not Found: not found" status string (S3 sink path
// where AWS SDK breaks the errors.Is unwrap chain)
// - LookupFileId or volume-id-not-found errors from the volume id map
func isIgnorable404(err error) bool {
if errors.Is(err, http.ErrNotFound) {
return true
}
errStr := err.Error()
return strings.Contains(errStr, ignorable404ErrString) ||
strings.Contains(errStr, "LookupFileId") ||
(strings.Contains(errStr, "volume id") && strings.Contains(errStr, "not found"))
}
+73
View File
@@ -0,0 +1,73 @@
package command
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func TestMain(m *testing.M) {
util_http.InitGlobalHttpClient()
os.Exit(m.Run())
}
// readUrlError starts a test HTTP server returning the given status code
// and returns the error produced by ReadUrlAsStream.
//
// The error format is defined in ReadUrlAsStream:
// https://github.com/seaweedfs/seaweedfs/blob/3a765df2ff90839acb9acf910b73513417fa84d1/weed/util/http/http_global_client_util.go#L353
func readUrlError(t *testing.T, statusCode int) error {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(statusCode), statusCode)
}))
defer server.Close()
_, err := util_http.ReadUrlAsStream(context.Background(),
server.URL+"/437,03f591a3a2b95e?readDeleted=true", "",
nil, false, true, 0, 1024, func(data []byte) {})
if err == nil {
t.Fatal("expected error from ReadUrlAsStream, got nil")
}
return err
}
func TestIsIgnorable404_WrappedErrNotFound(t *testing.T) {
readErr := readUrlError(t, http.StatusNotFound)
// genProcessFunction wraps sink errors with %w:
// https://github.com/seaweedfs/seaweedfs/blob/3a765df2ff90839acb9acf910b73513417fa84d1/weed/command/filer_sync.go#L496
genErr := fmt.Errorf("create entry1 : %w", readErr)
if !isIgnorable404(genErr) {
t.Errorf("expected ignorable, got not: %v", genErr)
}
}
func TestIsIgnorable404_BrokenUnwrapChain(t *testing.T) {
readErr := readUrlError(t, http.StatusNotFound)
// AWS SDK v1 wraps transport errors via awserr.New which uses origErr.Error()
// instead of %w, so errors.Is cannot unwrap through it:
// https://github.com/aws/aws-sdk-go/blob/v1.55.8/aws/corehandlers/handlers.go#L173
// https://github.com/aws/aws-sdk-go/blob/v1.55.8/aws/awserr/types.go#L15
awsSdkErr := fmt.Errorf("RequestError: send request failed\n"+
"caused by: Put \"https://s3.amazonaws.com/bucket/key\": %s", readErr.Error())
genErr := fmt.Errorf("create entry1 : %w", awsSdkErr)
if !isIgnorable404(genErr) {
t.Errorf("expected ignorable, got not: %v", genErr)
}
}
func TestIsIgnorable404_NonIgnorableError(t *testing.T) {
readErr := readUrlError(t, http.StatusForbidden)
genErr := fmt.Errorf("create entry1 : %w", readErr)
if isIgnorable404(genErr) {
t.Errorf("expected not ignorable, got ignorable: %v", genErr)
}
}
+91 -3
View File
@@ -21,6 +21,7 @@ import (
statsCollect "github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/grace"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"google.golang.org/grpc"
)
@@ -304,7 +305,7 @@ func doSubscribeFilerMetaChanges(clientId int32, clientEpoch int32, grpcDialOpti
filerSink.SetChunkConcurrency(chunkConcurrency)
filerSink.SetSourceFiler(filerSource)
persistEventFn := genProcessFunction(sourcePath, targetPath, sourceExcludePaths, nil, filerSink, doDeleteFiles, debug)
persistEventFn := genProcessFunction(sourcePath, targetPath, sourceExcludePaths, nil, nil, nil, filerSink, doDeleteFiles, debug)
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
message := resp.EventNotification
@@ -439,7 +440,7 @@ func setOffset(grpcDialOption grpc.DialOption, filer pb.ServerAddress, signature
}
func genProcessFunction(sourcePath string, targetPath string, excludePaths []string, reExcludeFileName *regexp.Regexp, dataSink sink.ReplicationSink, doDeleteFiles bool, debug bool) func(resp *filer_pb.SubscribeMetadataResponse) error {
func genProcessFunction(sourcePath string, targetPath string, excludePaths []string, reExcludeFileName *regexp.Regexp, excludeFileNames []*wildcard.WildcardMatcher, excludePathPatterns []*wildcard.WildcardMatcher, dataSink sink.ReplicationSink, doDeleteFiles bool, debug bool) func(resp *filer_pb.SubscribeMetadataResponse) error {
// process function
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
message := resp.EventNotification
@@ -468,9 +469,24 @@ func genProcessFunction(sourcePath string, targetPath string, excludePaths []str
return nil
}
}
if reExcludeFileName != nil && reExcludeFileName.MatchString(message.NewEntry.Name) {
// Compute per-side exclusion so that rename events crossing an
// exclude boundary are handled as delete + create rather than
// being entirely skipped.
oldExcluded := isEntryExcluded(resp.Directory, message.OldEntry, reExcludeFileName, excludeFileNames, excludePathPatterns)
newExcluded := isEntryExcluded(message.NewParentPath, message.NewEntry, reExcludeFileName, excludeFileNames, excludePathPatterns)
if oldExcluded && newExcluded {
return nil
}
if oldExcluded {
// Old side is excluded — treat as pure create of new entry.
message.OldEntry = nil
}
if newExcluded {
// New side is excluded — treat as pure delete of old entry.
message.NewEntry = nil
sourceNewKey = ""
}
if dataSink.IsIncremental() {
doDeleteFiles = false
}
@@ -578,3 +594,75 @@ func buildKey(dataSink sink.ReplicationSink, message *filer_pb.EventNotification
return escapeKey(key)
}
// isEntryExcluded checks whether a single side (old or new) of an event is excluded
// by the deprecated filename regexp, the wildcard file-name matchers, or the
// wildcard path-pattern matchers.
func isEntryExcluded(dir string, entry *filer_pb.Entry, reExcludeFileName *regexp.Regexp, excludeFileNames []*wildcard.WildcardMatcher, excludePathPatterns []*wildcard.WildcardMatcher) bool {
if entry == nil {
return false
}
// deprecated regexp-based filename exclusion
if reExcludeFileName != nil && reExcludeFileName.MatchString(entry.Name) {
return true
}
// wildcard-based filename exclusion
if len(excludeFileNames) > 0 && matchesAnyWildcard(excludeFileNames, entry.Name) {
return true
}
// wildcard-based path-pattern exclusion: match against each directory
// component and the entry name itself
if len(excludePathPatterns) > 0 {
if pathContainsWildcardMatch(dir, excludePathPatterns) {
return true
}
if matchesAnyWildcard(excludePathPatterns, entry.Name) {
return true
}
}
return false
}
// compileExcludePattern compiles a regexp pattern string, returning nil if empty.
func compileExcludePattern(pattern string, label string) (*regexp.Regexp, error) {
if pattern == "" {
return nil, nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("error compile regexp %v for %s: %+v", pattern, label, err)
}
return re, nil
}
// matchesAnyWildcard returns true if any matcher matches the value.
// Returns false when matchers is empty (unlike wildcard.MatchesAnyWildcard
// which returns true for empty matchers).
func matchesAnyWildcard(matchers []*wildcard.WildcardMatcher, value string) bool {
for _, m := range matchers {
if m != nil && m.Match(value) {
return true
}
}
return false
}
// pathContainsWildcardMatch checks if any component of the given path matches
// any of the wildcard matchers, without allocating a slice.
func pathContainsWildcardMatch(path string, matchers []*wildcard.WildcardMatcher) bool {
for path != "" {
i := strings.IndexByte(path, '/')
var component string
if i < 0 {
component = path
path = ""
} else {
component = path[:i]
path = path[i+1:]
}
if component != "" && matchesAnyWildcard(matchers, component) {
return true
}
}
return false
}
+164 -70
View File
@@ -1,6 +1,8 @@
package command
import (
"container/heap"
"path"
"sync"
"sync/atomic"
@@ -10,26 +12,154 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
)
// tsMinHeap implements heap.Interface for int64 timestamps.
type tsMinHeap []int64
func (h tsMinHeap) Len() int { return len(h) }
func (h tsMinHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h tsMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *tsMinHeap) Push(x any) { *h = append(*h, x.(int64)) }
func (h *tsMinHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
type syncJobPaths struct {
path util.FullPath
newPath util.FullPath // empty for non-renames
isDirectory bool
}
type MetadataProcessor struct {
activeJobs map[int64]*filer_pb.SubscribeMetadataResponse
activeJobsLock sync.Mutex
activeJobsCond *sync.Cond
concurrencyLimit int
fn pb.ProcessMetadataFunc
activeJobs map[int64]*syncJobPaths
activeJobsLock sync.Mutex
activeJobsCond *sync.Cond
concurrencyLimit int
fn pb.ProcessMetadataFunc
processedTsWatermark atomic.Int64
// Indexes for O(depth) conflict detection, replacing O(n) linear scan.
// activeFilePaths counts active file jobs at each exact path.
activeFilePaths map[util.FullPath]int
// activeDirPaths counts active directory jobs at each exact path.
activeDirPaths map[util.FullPath]int
// descendantCount counts active jobs (file or dir) strictly under each directory.
descendantCount map[util.FullPath]int
// tsHeap is a min-heap of active job timestamps with lazy deletion,
// used for O(log n) amortized watermark tracking.
tsHeap tsMinHeap
}
func NewMetadataProcessor(fn pb.ProcessMetadataFunc, concurrency int, offsetTsNs int64) *MetadataProcessor {
t := &MetadataProcessor{
fn: fn,
activeJobs: make(map[int64]*filer_pb.SubscribeMetadataResponse),
activeJobs: make(map[int64]*syncJobPaths),
concurrencyLimit: concurrency,
activeFilePaths: make(map[util.FullPath]int),
activeDirPaths: make(map[util.FullPath]int),
descendantCount: make(map[util.FullPath]int),
}
t.processedTsWatermark.Store(offsetTsNs)
t.activeJobsCond = sync.NewCond(&t.activeJobsLock)
return t
}
// pathAncestors returns all proper ancestor directories of p.
// For "/a/b/c", returns ["/a/b", "/a", "/"].
func pathAncestors(p util.FullPath) []util.FullPath {
var ancestors []util.FullPath
s := string(p)
for {
parent := path.Dir(s)
if parent == s {
break
}
ancestors = append(ancestors, util.FullPath(parent))
s = parent
}
return ancestors
}
// addPathToIndex registers a path in the conflict detection indexes.
// Must be called under activeJobsLock.
func (t *MetadataProcessor) addPathToIndex(p util.FullPath, isDirectory bool) {
if isDirectory {
t.activeDirPaths[p]++
} else {
t.activeFilePaths[p]++
}
for _, ancestor := range pathAncestors(p) {
t.descendantCount[ancestor]++
}
}
// removePathFromIndex unregisters a path from the conflict detection indexes.
// Must be called under activeJobsLock.
func (t *MetadataProcessor) removePathFromIndex(p util.FullPath, isDirectory bool) {
if isDirectory {
if t.activeDirPaths[p] <= 1 {
delete(t.activeDirPaths, p)
} else {
t.activeDirPaths[p]--
}
} else {
if t.activeFilePaths[p] <= 1 {
delete(t.activeFilePaths, p)
} else {
t.activeFilePaths[p]--
}
}
for _, ancestor := range pathAncestors(p) {
if t.descendantCount[ancestor] <= 1 {
delete(t.descendantCount, ancestor)
} else {
t.descendantCount[ancestor]--
}
}
}
// pathConflicts checks if a single path conflicts with any active job.
// Conflict rules match pairShouldWaitFor:
// - file vs file: exact same path
// - file vs dir: file.IsUnder(dir)
// - dir vs file: file.IsUnder(dir)
// - dir vs dir: either IsUnder the other
func (t *MetadataProcessor) pathConflicts(p util.FullPath, isDirectory bool) bool {
if isDirectory {
// Any active job (file or dir) strictly under this directory?
if t.descendantCount[p] > 0 {
return true
}
} else {
// Exact same file already active?
if t.activeFilePaths[p] > 0 {
return true
}
}
// Any active directory that is a proper ancestor of p?
for _, ancestor := range pathAncestors(p) {
if t.activeDirPaths[ancestor] > 0 {
return true
}
}
return false
}
func (t *MetadataProcessor) conflictsWith(resp *filer_pb.SubscribeMetadataResponse) bool {
p, newPath, isDirectory := extractPathsFromMetadata(resp)
if t.pathConflicts(p, isDirectory) {
return true
}
if newPath != "" && t.pathConflicts(newPath, isDirectory) {
return true
}
return false
}
func (t *MetadataProcessor) AddSyncJob(resp *filer_pb.SubscribeMetadataResponse) {
if filer_pb.IsEmpty(resp) {
return
@@ -41,7 +171,18 @@ func (t *MetadataProcessor) AddSyncJob(resp *filer_pb.SubscribeMetadataResponse)
for len(t.activeJobs) >= t.concurrencyLimit || t.conflictsWith(resp) {
t.activeJobsCond.Wait()
}
t.activeJobs[resp.TsNs] = resp
p, newPath, isDirectory := extractPathsFromMetadata(resp)
jobPaths := &syncJobPaths{path: p, newPath: newPath, isDirectory: isDirectory}
t.activeJobs[resp.TsNs] = jobPaths
t.addPathToIndex(p, isDirectory)
if newPath != "" {
t.addPathToIndex(newPath, isDirectory)
}
heap.Push(&t.tsHeap, resp.TsNs)
go func() {
if err := util.Retry("metadata processor", func() error {
@@ -54,95 +195,48 @@ func (t *MetadataProcessor) AddSyncJob(resp *filer_pb.SubscribeMetadataResponse)
defer t.activeJobsLock.Unlock()
delete(t.activeJobs, resp.TsNs)
t.removePathFromIndex(jobPaths.path, jobPaths.isDirectory)
if jobPaths.newPath != "" {
t.removePathFromIndex(jobPaths.newPath, jobPaths.isDirectory)
}
// if is the oldest job, write down the watermark
isOldest := true
for t := range t.activeJobs {
if resp.TsNs > t {
isOldest = false
// Lazy-clean stale entries from heap top (already-completed jobs).
// Each entry is pushed once and popped once: O(log n) amortized.
for t.tsHeap.Len() > 0 {
if _, active := t.activeJobs[t.tsHeap[0]]; active {
break
}
heap.Pop(&t.tsHeap)
}
if isOldest {
// If this was the oldest job, advance the watermark.
if t.tsHeap.Len() == 0 || resp.TsNs < t.tsHeap[0] {
t.processedTsWatermark.Store(resp.TsNs)
}
t.activeJobsCond.Signal()
}()
}
func (t *MetadataProcessor) conflictsWith(resp *filer_pb.SubscribeMetadataResponse) bool {
for _, r := range t.activeJobs {
if shouldWaitFor(resp, r) {
return true
}
}
return false
}
// a is one possible job to schedule
// b is one existing active job
func shouldWaitFor(a *filer_pb.SubscribeMetadataResponse, b *filer_pb.SubscribeMetadataResponse) bool {
aPath, aNewPath, aIsDirectory := extractPathsFromMetadata(a)
bPath, bNewPath, bIsDirectory := extractPathsFromMetadata(b)
if pairShouldWaitFor(aPath, bPath, aIsDirectory, bIsDirectory) {
return true
}
if aNewPath != "" {
if pairShouldWaitFor(aNewPath, bPath, aIsDirectory, bIsDirectory) {
return true
}
}
if bNewPath != "" {
if pairShouldWaitFor(aPath, bNewPath, aIsDirectory, bIsDirectory) {
return true
}
}
if aNewPath != "" && bNewPath != "" {
if pairShouldWaitFor(aNewPath, bNewPath, aIsDirectory, bIsDirectory) {
return true
}
}
return false
}
func pairShouldWaitFor(aPath, bPath util.FullPath, aIsDirectory, bIsDirectory bool) bool {
if bIsDirectory {
if aIsDirectory {
return aPath.IsUnder(bPath) || bPath.IsUnder(aPath)
} else {
return aPath.IsUnder(bPath)
}
} else {
if aIsDirectory {
return bPath.IsUnder(aPath)
} else {
return aPath == bPath
}
}
}
func extractPathsFromMetadata(resp *filer_pb.SubscribeMetadataResponse) (path, newPath util.FullPath, isDirectory bool) {
func extractPathsFromMetadata(resp *filer_pb.SubscribeMetadataResponse) (p, newPath util.FullPath, isDirectory bool) {
oldEntry := resp.EventNotification.OldEntry
newEntry := resp.EventNotification.NewEntry
// create
if filer_pb.IsCreate(resp) {
path = util.FullPath(resp.Directory).Child(newEntry.Name)
p = util.FullPath(resp.Directory).Child(newEntry.Name)
isDirectory = newEntry.IsDirectory
return
}
if filer_pb.IsDelete(resp) {
path = util.FullPath(resp.Directory).Child(oldEntry.Name)
p = util.FullPath(resp.Directory).Child(oldEntry.Name)
isDirectory = oldEntry.IsDirectory
return
}
if filer_pb.IsUpdate(resp) {
path = util.FullPath(resp.Directory).Child(newEntry.Name)
p = util.FullPath(resp.Directory).Child(newEntry.Name)
isDirectory = newEntry.IsDirectory
return
}
// renaming
path = util.FullPath(resp.Directory).Child(oldEntry.Name)
p = util.FullPath(resp.Directory).Child(oldEntry.Name)
isDirectory = oldEntry.IsDirectory
newPath = util.FullPath(resp.EventNotification.NewParentPath).Child(newEntry.Name)
return
+387
View File
@@ -0,0 +1,387 @@
package command
import (
"container/heap"
"fmt"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func makeResp(dir, name string, isDir bool, tsNs int64, isNew bool) *filer_pb.SubscribeMetadataResponse {
resp := &filer_pb.SubscribeMetadataResponse{
Directory: dir,
TsNs: tsNs,
EventNotification: &filer_pb.EventNotification{},
}
entry := &filer_pb.Entry{
Name: name,
IsDirectory: isDir,
}
if isNew {
resp.EventNotification.NewEntry = entry
} else {
resp.EventNotification.OldEntry = entry
}
return resp
}
func makeRenameResp(oldDir, oldName, newDir, newName string, isDir bool, tsNs int64) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: oldDir,
TsNs: tsNs,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{
Name: oldName,
IsDirectory: isDir,
},
NewEntry: &filer_pb.Entry{
Name: newName,
IsDirectory: isDir,
},
NewParentPath: newDir,
},
}
}
func TestPathAncestors(t *testing.T) {
tests := []struct {
path util.FullPath
expected []util.FullPath
}{
{"/a/b/c/file.txt", []util.FullPath{"/a/b/c", "/a/b", "/a", "/"}},
{"/a/b", []util.FullPath{"/a", "/"}},
{"/a", []util.FullPath{"/"}},
{"/", nil},
}
for _, tt := range tests {
got := pathAncestors(tt.path)
if len(got) != len(tt.expected) {
t.Errorf("pathAncestors(%q) = %v, want %v", tt.path, got, tt.expected)
continue
}
for i := range got {
if got[i] != tt.expected[i] {
t.Errorf("pathAncestors(%q)[%d] = %q, want %q", tt.path, i, got[i], tt.expected[i])
}
}
}
}
// TestFileVsFileConflict verifies that two file operations on the same path conflict,
// and on different paths do not.
func TestFileVsFileConflict(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add a file job
active := makeResp("/dir1", "file.txt", false, 1, true)
path, newPath, isDir := extractPathsFromMetadata(active)
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
// Same file should conflict
same := makeResp("/dir1", "file.txt", false, 2, true)
if !p.conflictsWith(same) {
t.Error("expected conflict for same file path")
}
// Different file should not conflict
diff := makeResp("/dir1", "other.txt", false, 3, true)
if p.conflictsWith(diff) {
t.Error("unexpected conflict for different file path")
}
// File in different directory should not conflict
diffDir := makeResp("/dir2", "file.txt", false, 4, true)
if p.conflictsWith(diffDir) {
t.Error("unexpected conflict for file in different directory")
}
}
// TestFileUnderActiveDirConflict verifies that a file under an active directory operation
// conflicts, but a file outside does not.
func TestFileUnderActiveDirConflict(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add a directory job at /dir1
active := makeResp("/", "dir1", true, 1, true)
path, newPath, isDir := extractPathsFromMetadata(active)
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
// File under /dir1 should conflict
under := makeResp("/dir1", "file.txt", false, 2, true)
if !p.conflictsWith(under) {
t.Error("expected conflict for file under active directory")
}
// File deeply nested under /dir1 should conflict
deep := makeResp("/dir1/sub/deep", "file.txt", false, 3, true)
if !p.conflictsWith(deep) {
t.Error("expected conflict for deeply nested file under active directory")
}
// File in /dir2 should not conflict
outside := makeResp("/dir2", "file.txt", false, 4, true)
if p.conflictsWith(outside) {
t.Error("unexpected conflict for file outside active directory")
}
// File at /dir1 itself (not under, at) should not conflict
// because IsUnder is strict: "/dir1".IsUnder("/dir1") == false
atSame := makeResp("/", "dir1", false, 5, true)
if p.conflictsWith(atSame) {
t.Error("unexpected conflict for file at same path as directory (IsUnder is strict)")
}
}
// TestDirWithActiveFileUnder verifies that a directory operation conflicts when
// there are active file jobs under it.
func TestDirWithActiveFileUnder(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add file jobs under /dir1
f1 := makeResp("/dir1/sub", "file.txt", false, 1, true)
path, newPath, isDir := extractPathsFromMetadata(f1)
p.activeJobs[f1.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
// Directory /dir1 should conflict (has active file under it)
dirOp := makeResp("/", "dir1", true, 2, true)
if !p.conflictsWith(dirOp) {
t.Error("expected conflict for directory with active file under it")
}
// Directory /dir2 should not conflict
dirOp2 := makeResp("/", "dir2", true, 3, true)
if p.conflictsWith(dirOp2) {
t.Error("unexpected conflict for directory with no active jobs under it")
}
}
// TestDirVsDirConflict verifies ancestor/descendant directory conflict detection.
func TestDirVsDirConflict(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add directory job at /a/b
active := makeResp("/a", "b", true, 1, true)
path, newPath, isDir := extractPathsFromMetadata(active)
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
// /a/b/c (descendant) should conflict
desc := makeResp("/a/b", "c", true, 2, true)
if !p.conflictsWith(desc) {
t.Error("expected conflict for descendant directory")
}
// /a (ancestor) should conflict
anc := makeResp("/", "a", true, 3, true)
if !p.conflictsWith(anc) {
t.Error("expected conflict for ancestor directory")
}
// Same directory should NOT conflict (IsUnder is strict, not equal)
same := makeResp("/a", "b", true, 4, true)
if p.conflictsWith(same) {
t.Error("unexpected conflict for same directory (IsUnder is strict)")
}
// Sibling directory should not conflict
sibling := makeResp("/a", "c", true, 5, true)
if p.conflictsWith(sibling) {
t.Error("unexpected conflict for sibling directory")
}
}
// TestRenameConflict verifies that rename events with two paths check both paths.
func TestRenameConflict(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add file job at /dir1/file.txt
f1 := makeResp("/dir1", "file.txt", false, 1, true)
path, newPath, isDir := extractPathsFromMetadata(f1)
p.activeJobs[f1.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
// Rename from /dir2/a.txt to /dir1/file.txt should conflict (newPath matches)
rename := makeRenameResp("/dir2", "a.txt", "/dir1", "file.txt", false, 2)
if !p.conflictsWith(rename) {
t.Error("expected conflict for rename whose destination matches active file")
}
// Rename from /dir1/file.txt to /dir2/b.txt should conflict (oldPath matches)
rename2 := makeRenameResp("/dir1", "file.txt", "/dir2", "b.txt", false, 3)
if !p.conflictsWith(rename2) {
t.Error("expected conflict for rename whose source matches active file")
}
// Rename between unrelated paths should not conflict
rename3 := makeRenameResp("/dir3", "x.txt", "/dir4", "y.txt", false, 4)
if p.conflictsWith(rename3) {
t.Error("unexpected conflict for rename between unrelated paths")
}
}
// TestActiveRenameConflict verifies that an active rename job registers both paths.
func TestActiveRenameConflict(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add active rename job: /dir1/old.txt -> /dir2/new.txt
rename := makeRenameResp("/dir1", "old.txt", "/dir2", "new.txt", false, 1)
path, newPath, isDir := extractPathsFromMetadata(rename)
p.activeJobs[rename.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
if newPath != "" {
p.addPathToIndex(newPath, isDir)
}
// File at /dir1/old.txt should conflict
f1 := makeResp("/dir1", "old.txt", false, 2, true)
if !p.conflictsWith(f1) {
t.Error("expected conflict at rename source path")
}
// File at /dir2/new.txt should conflict
f2 := makeResp("/dir2", "new.txt", false, 3, true)
if !p.conflictsWith(f2) {
t.Error("expected conflict at rename destination path")
}
// File at unrelated path should not conflict
f3 := makeResp("/dir3", "other.txt", false, 4, true)
if p.conflictsWith(f3) {
t.Error("unexpected conflict at unrelated path")
}
}
// TestRootDirConflict verifies that an active job at / conflicts with everything.
func TestRootDirConflict(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add directory job at /
// Note: a dir entry at "/" would be created as FullPath("/").Child("somedir")
// But let's test what happens with an active dir at /some/path and check root
active := makeResp("/some", "dir", true, 1, true)
path, newPath, isDir := extractPathsFromMetadata(active)
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
// Root dir should conflict because active dir /some/dir is under /
// A new directory at "/" should see descendantCount["/"] > 0
if p.descendantCount["/"] <= 0 {
t.Error("expected descendantCount['/'] > 0 for active job under root")
}
}
// TestIndexCleanup verifies that removing a job properly cleans up all indexes.
func TestIndexCleanup(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Add then remove a file job
path := util.FullPath("/a/b/c/file.txt")
p.addPathToIndex(path, false)
if p.activeFilePaths[path] != 1 {
t.Errorf("expected activeFilePaths count 1, got %d", p.activeFilePaths[path])
}
if p.descendantCount["/a/b/c"] != 1 {
t.Errorf("expected descendantCount['/a/b/c'] = 1, got %d", p.descendantCount["/a/b/c"])
}
p.removePathFromIndex(path, false)
if len(p.activeFilePaths) != 0 {
t.Errorf("expected empty activeFilePaths after removal, got %v", p.activeFilePaths)
}
if len(p.descendantCount) != 0 {
t.Errorf("expected empty descendantCount after removal, got %v", p.descendantCount)
}
}
// TestWatermarkWithHeap verifies watermark advancement using the min-heap.
func TestWatermarkWithHeap(t *testing.T) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, 100, 0)
// Simulate adding jobs in order
for _, ts := range []int64{10, 20, 30} {
jobPath := util.FullPath("/file" + string(rune('0'+ts/10)))
p.activeJobs[ts] = &syncJobPaths{path: jobPath, isDirectory: false}
p.addPathToIndex(jobPath, false)
heap.Push(&p.tsHeap, ts)
}
if p.tsHeap[0] != 10 {
t.Errorf("expected heap min=10, got %d", p.tsHeap[0])
}
// Remove non-oldest (ts=20) — heap top should stay 10
delete(p.activeJobs, 20)
p.removePathFromIndex("/file2", false)
// Lazy clean: top is 10 which is still active, so no pop
for p.tsHeap.Len() > 0 {
if _, active := p.activeJobs[p.tsHeap[0]]; active {
break
}
heap.Pop(&p.tsHeap)
}
if p.tsHeap[0] != 10 {
t.Errorf("expected heap min=10 after removing 20, got %d", p.tsHeap[0])
}
// Remove oldest (ts=10) — lazy clean should find 30
delete(p.activeJobs, 10)
p.removePathFromIndex("/file1", false)
for p.tsHeap.Len() > 0 {
if _, active := p.activeJobs[p.tsHeap[0]]; active {
break
}
heap.Pop(&p.tsHeap)
}
if p.tsHeap.Len() != 1 || p.tsHeap[0] != 30 {
t.Errorf("expected heap min=30 after removing 10 and 20, got len=%d", p.tsHeap.Len())
}
}
// benchResult prevents the compiler from optimizing away the conflict check.
var benchResult bool
// BenchmarkConflictCheck measures conflict check cost with varying active job counts.
// With the index-based approach, cost should be O(depth) regardless of job count.
func BenchmarkConflictCheck(b *testing.B) {
for _, numJobs := range []int{32, 256, 1024} {
b.Run(fmt.Sprintf("jobs=%d", numJobs), func(b *testing.B) {
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
p := NewMetadataProcessor(noop, numJobs+1, 0)
// Fill with active jobs in different directories
for i := range numJobs {
dir := fmt.Sprintf("/dir%d/sub%d", i/100, i%100)
name := fmt.Sprintf("file%d.txt", i)
resp := makeResp(dir, name, false, int64(i+1), true)
path, newPath, isDir := extractPathsFromMetadata(resp)
p.activeJobs[resp.TsNs] = &syncJobPaths{path: path, newPath: newPath, isDirectory: isDir}
p.addPathToIndex(path, isDir)
}
// Benchmark conflict check for a non-conflicting event
probe := makeResp("/other/path", "test.txt", false, int64(numJobs+1), true)
var r bool
b.ResetTimer()
for i := 0; i < b.N; i++ {
r = p.conflictsWith(probe)
}
benchResult = r
})
}
}
+32 -30
View File
@@ -252,7 +252,7 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
SingleThreaded: false,
DisableXAttrs: *option.disableXAttr,
Debug: *option.debug,
EnableLocks: false,
EnableLocks: true,
ExplicitDataCacheControl: false,
DirectMount: true,
DirectMountFlags: 0,
@@ -312,35 +312,37 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
}
seaweedFileSystem := mount.NewSeaweedFileSystem(&mount.Option{
MountDirectory: dir,
FilerAddresses: filerAddresses,
GrpcDialOption: grpcDialOption,
FilerMountRootPath: mountRoot,
Collection: *option.collection,
Replication: *option.replication,
TtlSec: int32(*option.ttlSec),
DiskType: types.ToDiskType(*option.diskType),
ChunkSizeLimit: int64(chunkSizeLimitMB) * 1024 * 1024,
ConcurrentWriters: *option.concurrentWriters,
ConcurrentReaders: *option.concurrentReaders,
CacheDirForRead: *option.cacheDirForRead,
CacheSizeMBForRead: *option.cacheSizeMBForRead,
CacheDirForWrite: cacheDirForWrite,
CacheMetaTTlSec: *option.cacheMetaTtlSec,
DataCenter: *option.dataCenter,
Quota: int64(*option.collectionQuota) * 1024 * 1024,
MountUid: uid,
MountGid: gid,
MountMode: mountMode,
MountCtime: fileInfo.ModTime(),
MountMtime: time.Now(),
Umask: umask,
VolumeServerAccess: *mountOptions.volumeServerAccess,
Cipher: cipher,
UidGidMapper: uidGidMapper,
DisableXAttr: *option.disableXAttr,
IsMacOs: runtime.GOOS == "darwin",
MetadataFlushSeconds: *option.metadataFlushSeconds,
MountDirectory: dir,
FilerAddresses: filerAddresses,
GrpcDialOption: grpcDialOption,
FilerSigningKey: security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key")),
FilerSigningExpiresAfterSec: util.GetViper().GetInt("jwt.filer_signing.expires_after_seconds"),
FilerMountRootPath: mountRoot,
Collection: *option.collection,
Replication: *option.replication,
TtlSec: int32(*option.ttlSec),
DiskType: types.ToDiskType(*option.diskType),
ChunkSizeLimit: int64(chunkSizeLimitMB) * 1024 * 1024,
ConcurrentWriters: *option.concurrentWriters,
ConcurrentReaders: *option.concurrentReaders,
CacheDirForRead: *option.cacheDirForRead,
CacheSizeMBForRead: *option.cacheSizeMBForRead,
CacheDirForWrite: cacheDirForWrite,
CacheMetaTTlSec: *option.cacheMetaTtlSec,
DataCenter: *option.dataCenter,
Quota: int64(*option.collectionQuota) * 1024 * 1024,
MountUid: uid,
MountGid: gid,
MountMode: mountMode,
MountCtime: fileInfo.ModTime(),
MountMtime: time.Now(),
Umask: umask,
VolumeServerAccess: *mountOptions.volumeServerAccess,
Cipher: cipher,
UidGidMapper: uidGidMapper,
DisableXAttr: *option.disableXAttr,
IsMacOs: runtime.GOOS == "darwin",
MetadataFlushSeconds: *option.metadataFlushSeconds,
// RDMA acceleration options
RdmaEnabled: *option.rdmaEnabled,
RdmaSidecarAddr: *option.rdmaSidecarAddr,
+17
View File
@@ -0,0 +1,17 @@
package filer
const (
CopyQueryParamFrom = "cp.from"
CopyQueryParamOverwrite = "overwrite"
CopyQueryParamDataOnly = "dataOnly"
CopyQueryParamRequestID = "copy.requestId"
CopyQueryParamSourceInode = "copy.srcInode"
CopyQueryParamSourceMtime = "copy.srcMtime"
CopyQueryParamSourceSize = "copy.srcSize"
CopyQueryParamDestinationInode = "copy.dstInode"
CopyQueryParamDestinationMtime = "copy.dstMtime"
CopyQueryParamDestinationSize = "copy.dstSize"
CopyResponseHeaderCommitted = "X-SeaweedFS-Copy-Committed"
CopyResponseHeaderRequestID = "X-SeaweedFS-Copy-Request-ID"
)
@@ -308,7 +308,7 @@ func (efc *EmptyFolderCleaner) executeCleanup(folder string, triggeredBy string)
efc.mu.Unlock()
if count > 0 {
glog.Infof("EmptyFolderCleaner: folder %s (triggered by %s) has %d items, not empty", folder, triggeredBy, count)
glog.V(4).Infof("EmptyFolderCleaner: folder %s (triggered by %s) has %d items, not empty", folder, triggeredBy, count)
return
}
+15 -7
View File
@@ -81,7 +81,7 @@ func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerH
f.UniqueFilerId = -f.UniqueFilerId
}
f.LocalMetaLogBuffer = log_buffer.NewLogBuffer("local", LogFlushInterval, f.logFlushFunc, nil, notifyFn)
f.LocalMetaLogBuffer = log_buffer.NewLogBuffer("local", LogFlushInterval, f.logFlushFunc, f.readPersistedLogBufferPosition, notifyFn)
f.metaLogCollection = collection
f.metaLogReplication = replication
@@ -203,7 +203,7 @@ func (f *Filer) CreateEntry(ctx context.Context, entry *Entry, o_excl bool, isFr
}
if entry.FullPath.IsLongerFileName(maxFilenameLength) {
return fmt.Errorf("entry name too long")
return filer_pb.ErrEntryNameTooLong
}
if entry.IsDirectory() {
@@ -237,7 +237,7 @@ func (f *Filer) CreateEntry(ctx context.Context, entry *Entry, o_excl bool, isFr
} else {
if o_excl {
glog.V(3).InfofCtx(ctx, "EEXIST: entry %s already exists", entry.FullPath)
return fmt.Errorf("EEXIST: entry %s already exists", entry.FullPath)
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); err != nil {
@@ -323,8 +323,16 @@ func (f *Filer) ensureParentDirectoryEntry(ctx context.Context, entry *Entry, di
}
} else if !dirEntry.IsDirectory() {
glog.ErrorfCtx(ctx, "CreateEntry %s: %s should be a directory", entry.FullPath, dirPath)
return fmt.Errorf("%s is a file", dirPath)
// S3 allows both "foo/bar" (object) and "foo/bar/xyzzy" (another
// object) to coexist because S3 has a flat key space. Promote the
// existing file to a directory, preserving its content/chunks so
// the original object data remains accessible.
glog.V(2).InfofCtx(ctx, "promoting %s from file to directory for %s", dirPath, entry.FullPath)
dirEntry.Attr.Mode |= os.ModeDir | 0111
if updateErr := f.Store.UpdateEntry(ctx, dirEntry); updateErr != nil {
return fmt.Errorf("promote %s to directory: %v", dirPath, updateErr)
}
f.NotifyUpdateEvent(ctx, nil, dirEntry, false, isFromOtherCluster, nil)
}
return nil
@@ -335,11 +343,11 @@ func (f *Filer) UpdateEntry(ctx context.Context, oldEntry, entry *Entry) (err er
entry.Attr.Crtime = oldEntry.Attr.Crtime
if oldEntry.IsDirectory() && !entry.IsDirectory() {
glog.ErrorfCtx(ctx, "existing %s is a directory", oldEntry.FullPath)
return fmt.Errorf("existing %s is a directory", oldEntry.FullPath)
return fmt.Errorf("%s: %w", oldEntry.FullPath, filer_pb.ErrExistingIsDirectory)
}
if !oldEntry.IsDirectory() && entry.IsDirectory() {
glog.ErrorfCtx(ctx, "existing %s is a file", oldEntry.FullPath)
return fmt.Errorf("existing %s is a file", oldEntry.FullPath)
return fmt.Errorf("%s: %w", oldEntry.FullPath, filer_pb.ErrExistingIsFile)
}
}
return f.Store.UpdateEntry(ctx, entry)
+24 -1
View File
@@ -2,8 +2,10 @@ package filer
import (
"context"
"errors"
"fmt"
"io"
nethttp "net/http"
"regexp"
"strings"
"time"
@@ -16,6 +18,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/notification"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func (f *Filer) NotifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry, deleteChunks, isFromOtherCluster bool, signatures []int32) {
@@ -174,6 +177,7 @@ func (f *Filer) logFlushFunc(logBuffer *log_buffer.LogBuffer, startTime, stopTim
var (
volumeNotFoundPattern = regexp.MustCompile(`volume \d+? not found`)
chunkNotFoundPattern = regexp.MustCompile(`(urls not found|File Not Found)`)
httpNotFoundPattern = regexp.MustCompile(`404 Not Found: not found`)
)
// isChunkNotFoundError checks if the error indicates that a volume or chunk
@@ -183,8 +187,13 @@ func isChunkNotFoundError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, util_http.ErrNotFound) || errors.Is(err, nethttp.ErrMissingFile) {
return true
}
errMsg := err.Error()
return volumeNotFoundPattern.MatchString(errMsg) || chunkNotFoundPattern.MatchString(errMsg)
return volumeNotFoundPattern.MatchString(errMsg) ||
chunkNotFoundPattern.MatchString(errMsg) ||
httpNotFoundPattern.MatchString(errMsg)
}
func (f *Filer) ReadPersistedLogBuffer(startPosition log_buffer.MessagePosition, stopTsNs int64, eachLogEntryFn log_buffer.EachLogEntryFuncType) (lastTsNs int64, isDone bool, err error) {
@@ -220,3 +229,17 @@ func (f *Filer) ReadPersistedLogBuffer(startPosition log_buffer.MessagePosition,
return
}
func (f *Filer) readPersistedLogBufferPosition(startPosition log_buffer.MessagePosition, stopTsNs int64, eachLogEntryFn log_buffer.EachLogEntryFuncType) (lastReadPosition log_buffer.MessagePosition, isDone bool, err error) {
lastReadPosition = startPosition
lastTsNs, isDone, err := f.ReadPersistedLogBuffer(startPosition, stopTsNs, eachLogEntryFn)
if err != nil {
return lastReadPosition, isDone, err
}
if lastTsNs != 0 {
lastReadPosition = log_buffer.NewMessagePosition(lastTsNs, 1)
}
return lastReadPosition, isDone, nil
}
+21 -6
View File
@@ -3,6 +3,7 @@ package filer
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -17,6 +18,8 @@ func (fsw *FilerStoreWrapper) handleUpdateToHardLinks(ctx context.Context, entry
if len(entry.HardLinkId) > 0 {
// handle hard links
glog.V(4).InfofCtx(ctx, "handleUpdateToHardLinks %s HardLinkId %x counter=%d chunks=%d",
entry.FullPath, entry.HardLinkId, entry.HardLinkCounter, len(entry.GetChunks()))
if err := fsw.setHardLink(ctx, entry); err != nil {
return fmt.Errorf("setHardLink %d: %v", entry.HardLinkId, err)
}
@@ -51,7 +54,8 @@ func (fsw *FilerStoreWrapper) setHardLink(ctx context.Context, entry *Entry) err
return encodeErr
}
glog.V(4).InfofCtx(ctx, "setHardLink %v nlink:%d", entry.FullPath, entry.HardLinkCounter)
glog.V(4).InfofCtx(ctx, "setHardLink KvPut %s HardLinkId %x nlink:%d blobLen=%d",
entry.FullPath, entry.HardLinkId, entry.HardLinkCounter, len(newBlob))
return fsw.KvPut(ctx, key, newBlob)
}
@@ -62,26 +66,35 @@ func (fsw *FilerStoreWrapper) maybeReadHardLink(ctx context.Context, entry *Entr
}
key := entry.HardLinkId
glog.V(1).InfofCtx(ctx, "maybeReadHardLink %s HardLinkId %x", entry.FullPath, entry.HardLinkId)
value, err := fsw.KvGet(ctx, key)
if err != nil {
glog.ErrorfCtx(ctx, "read %s hardlink %d: %v", entry.FullPath, entry.HardLinkId, err)
if errors.Is(err, ErrKvNotFound) {
glog.V(4).InfofCtx(ctx, "maybeReadHardLink %s HardLinkId %x: not found", entry.FullPath, entry.HardLinkId)
} else {
glog.ErrorfCtx(ctx, "read %s HardLinkId %x: %v", entry.FullPath, entry.HardLinkId, err)
}
return err
}
if err = entry.DecodeAttributesAndChunks(value); err != nil {
glog.ErrorfCtx(ctx, "decode %s hardlink %d: %v", entry.FullPath, entry.HardLinkId, err)
glog.ErrorfCtx(ctx, "decode %s HardLinkId %x: %v", entry.FullPath, entry.HardLinkId, err)
return err
}
glog.V(4).InfofCtx(ctx, "maybeReadHardLink %v nlink:%d", entry.FullPath, entry.HardLinkCounter)
glog.V(1).InfofCtx(ctx, "maybeReadHardLink %s HardLinkId %x nlink:%d chunks=%d",
entry.FullPath, entry.HardLinkId, entry.HardLinkCounter, len(entry.GetChunks()))
return nil
}
func (fsw *FilerStoreWrapper) DeleteHardLink(ctx context.Context, hardLinkId HardLinkId) error {
key := hardLinkId
glog.V(4).InfofCtx(ctx, "DeleteHardLink HardLinkId %x", key)
value, err := fsw.KvGet(ctx, key)
if err == ErrKvNotFound {
glog.V(4).InfofCtx(ctx, "DeleteHardLink HardLinkId %x: already gone", key)
return nil
}
if err != nil {
@@ -95,7 +108,8 @@ func (fsw *FilerStoreWrapper) DeleteHardLink(ctx context.Context, hardLinkId Har
entry.HardLinkCounter--
if entry.HardLinkCounter <= 0 {
glog.V(4).InfofCtx(ctx, "DeleteHardLink KvDelete %v", key)
glog.V(4).InfofCtx(ctx, "DeleteHardLink KvDelete HardLinkId %x counter reached %d",
key, entry.HardLinkCounter)
return fsw.KvDelete(ctx, key)
}
@@ -104,7 +118,8 @@ func (fsw *FilerStoreWrapper) DeleteHardLink(ctx context.Context, hardLinkId Har
return encodeErr
}
glog.V(4).InfofCtx(ctx, "DeleteHardLink KvPut %v", key)
glog.V(4).InfofCtx(ctx, "DeleteHardLink KvPut HardLinkId %x counter decremented to %d",
key, entry.HardLinkCounter)
return fsw.KvPut(ctx, key, newBlob)
}
+10
View File
@@ -130,6 +130,11 @@ func (fsw *FilerStoreWrapper) InsertEntry(ctx context.Context, entry *Entry) err
filer_pb.BeforeEntrySerialization(entry.GetChunks())
normalizeEntryMimeForStore(entry)
if len(entry.HardLinkId) > 0 {
glog.V(4).InfofCtx(ctx, "InsertEntry %s has HardLinkId %x counter=%d",
entry.FullPath, entry.HardLinkId, entry.HardLinkCounter)
}
if err := fsw.handleUpdateToHardLinks(ctx, entry); err != nil {
return err
}
@@ -150,6 +155,11 @@ func (fsw *FilerStoreWrapper) UpdateEntry(ctx context.Context, entry *Entry) err
filer_pb.BeforeEntrySerialization(entry.GetChunks())
normalizeEntryMimeForStore(entry)
if len(entry.HardLinkId) > 0 {
glog.V(4).InfofCtx(ctx, "UpdateEntry %s has HardLinkId %x counter=%d",
entry.FullPath, entry.HardLinkId, entry.HardLinkCounter)
}
if err := fsw.handleUpdateToHardLinks(ctx, entry); err != nil {
return err
}
+12
View File
@@ -74,6 +74,18 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
}
}
func (ma *MetaAggregator) HasRemotePeers() bool {
ma.peerChansLock.Lock()
defer ma.peerChansLock.Unlock()
for address := range ma.peerChans {
if address != ma.self {
return true
}
}
return false
}
func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddress, peer pb.ServerAddress, startFrom time.Time, stopChan chan struct{}) {
lastTsNs := startFrom.UnixNano()
for {
+25 -4
View File
@@ -4,6 +4,7 @@ import (
"os"
"sync"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -31,10 +32,11 @@ type FileHandle struct {
asyncFlushPending bool // set in writebackCache mode to defer flush to Release
asyncFlushUid uint32 // saved uid for deferred metadata flush
asyncFlushGid uint32 // saved gid for deferred metadata flush
asyncFlushDir string // saved directory at defer time (fallback if inode forgotten)
asyncFlushName string // saved file name at defer time (fallback if inode forgotten)
savedDir string // last known parent path if inode-to-path state is forgotten
savedName string // last known file name if inode-to-path state is forgotten
isDeleted bool
isRenamed bool // set by Rename before waiting for async flush; skips old-path metadata flush
// RDMA chunk offset cache for performance optimization
chunkOffsetCache []int64
@@ -73,8 +75,20 @@ func newFileHandle(wfs *WFS, handleId FileHandleId, inode uint64, entry *filer_p
}
func (fh *FileHandle) FullPath() util.FullPath {
fp, _ := fh.wfs.inodeToPath.GetPath(fh.inode)
return fp
if fp, status := fh.wfs.inodeToPath.GetPath(fh.inode); status == fuse.OK {
return fp
}
if fh.savedName != "" {
return util.FullPath(fh.savedDir).Child(fh.savedName)
}
return ""
}
func (fh *FileHandle) RememberPath(fullPath util.FullPath) {
if fullPath == "" {
return
}
fh.savedDir, fh.savedName = fullPath.DirAndName()
}
func (fh *FileHandle) GetEntry() *LockedEntry {
@@ -99,6 +113,13 @@ func (fh *FileHandle) SetEntry(entry *filer_pb.Entry) {
fh.invalidateChunkCache()
}
func (fh *FileHandle) ResetDirtyPages() {
fh.dirtyPages.Destroy()
fh.dirtyPages = newPageWriter(fh, fh.wfs.option.ChunkSizeLimit)
fh.dirtyMetadata = false
fh.contentType = ""
}
func (fh *FileHandle) UpdateEntry(fn func(entry *filer_pb.Entry)) *filer_pb.Entry {
result := fh.entry.UpdateEntry(fn)
+11
View File
@@ -38,6 +38,17 @@ func (i *FileHandleToInode) FindFileHandle(inode uint64) (fh *FileHandle, found
return
}
// MarkInodeRenamed sets isRenamed on any file handle associated with the
// given inode. This prevents the async flush from recreating a renamed
// file's metadata under its old path.
func (i *FileHandleToInode) MarkInodeRenamed(inode uint64) {
i.RLock()
defer i.RUnlock()
if fh, ok := i.inode2fh[inode]; ok {
fh.isRenamed = true
}
}
func (i *FileHandleToInode) AcquireFileHandle(wfs *WFS, inode uint64, entry *filer_pb.Entry) *FileHandle {
i.Lock()
defer i.Unlock()
+52
View File
@@ -0,0 +1,52 @@
package mount
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestFileHandleFullPathFallsBackAfterForget(t *testing.T) {
wfs := &WFS{
inodeToPath: NewInodeToPath(util.FullPath("/"), 0),
}
fullPath := util.FullPath("/worker_0/subdir_0/test.txt")
inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true)
fh := &FileHandle{
inode: inode,
wfs: wfs,
}
fh.RememberPath(fullPath)
wfs.inodeToPath.Forget(inode, 1, nil)
if got := fh.FullPath(); got != fullPath {
t.Fatalf("FullPath() after forget = %q, want %q", got, fullPath)
}
}
func TestFileHandleFullPathUsesSavedRenamePathAfterForget(t *testing.T) {
wfs := &WFS{
inodeToPath: NewInodeToPath(util.FullPath("/"), 0),
}
oldPath := util.FullPath("/worker_0/subdir_0/test.txt")
newPath := util.FullPath("/worker_0/subdir_1/test.txt")
inode := wfs.inodeToPath.Lookup(oldPath, 1, false, false, 0, true)
fh := &FileHandle{
inode: inode,
wfs: wfs,
}
fh.RememberPath(oldPath)
wfs.inodeToPath.MovePath(oldPath, newPath)
fh.RememberPath(newPath)
wfs.inodeToPath.Forget(inode, 1, nil)
if got := fh.FullPath(); got != newPath {
t.Fatalf("FullPath() after rename+forget = %q, want %q", got, newPath)
}
}
+28
View File
@@ -0,0 +1,28 @@
package mount
import "time"
const metadataFlushRetries = 3
var metadataFlushSleep = time.Sleep
func retryMetadataFlush(flush func() error, onRetry func(nextAttempt, totalAttempts int, backoff time.Duration, err error)) error {
totalAttempts := metadataFlushRetries + 1
var err error
for attempt := 1; attempt <= totalAttempts; attempt++ {
err = flush()
if err == nil {
break
}
if attempt == totalAttempts {
break
}
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
if onRetry != nil {
onRetry(attempt+1, totalAttempts, backoff, err)
}
metadataFlushSleep(backoff)
}
return err
}
+81
View File
@@ -0,0 +1,81 @@
package mount
import (
"errors"
"testing"
"time"
)
func TestRetryMetadataFlushEventuallySucceeds(t *testing.T) {
originalSleep := metadataFlushSleep
t.Cleanup(func() {
metadataFlushSleep = originalSleep
})
var sleeps []time.Duration
metadataFlushSleep = func(d time.Duration) {
sleeps = append(sleeps, d)
}
attempts := 0
err := retryMetadataFlush(func() error {
attempts++
if attempts < 3 {
return errors.New("temporary failure")
}
return nil
}, nil)
if err != nil {
t.Fatalf("retryMetadataFlush returned error: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
wantSleeps := []time.Duration{time.Second, 2 * time.Second}
if len(sleeps) != len(wantSleeps) {
t.Fatalf("sleep count = %d, want %d", len(sleeps), len(wantSleeps))
}
for i, want := range wantSleeps {
if sleeps[i] != want {
t.Fatalf("sleep[%d] = %v, want %v", i, sleeps[i], want)
}
}
}
func TestRetryMetadataFlushReturnsLastError(t *testing.T) {
originalSleep := metadataFlushSleep
t.Cleanup(func() {
metadataFlushSleep = originalSleep
})
var sleeps []time.Duration
metadataFlushSleep = func(d time.Duration) {
sleeps = append(sleeps, d)
}
expectedErr := errors.New("permanent failure")
attempts := 0
err := retryMetadataFlush(func() error {
attempts++
return expectedErr
}, nil)
if !errors.Is(err, expectedErr) {
t.Fatalf("retryMetadataFlush error = %v, want %v", err, expectedErr)
}
if attempts != metadataFlushRetries+1 {
t.Fatalf("attempts = %d, want %d", attempts, metadataFlushRetries+1)
}
wantSleeps := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}
if len(sleeps) != len(wantSleeps) {
t.Fatalf("sleep count = %d, want %d", len(sleeps), len(wantSleeps))
}
for i, want := range wantSleeps {
if sleeps[i] != want {
t.Fatalf("sleep[%d] = %v, want %v", i, sleeps[i], want)
}
}
}
+361
View File
@@ -0,0 +1,361 @@
package mount
import (
"math"
"sort"
"sync"
"syscall"
"github.com/seaweedfs/go-fuse/v2/fuse"
)
// lockRange represents a single held POSIX byte-range lock.
type lockRange struct {
Start uint64 // inclusive byte offset
End uint64 // inclusive; math.MaxUint64 means "to EOF"
Typ uint32 // syscall.F_RDLCK or syscall.F_WRLCK
Owner uint64 // FUSE lock owner (from LkIn.Owner)
Pid uint32 // PID of lock holder (for GetLk reporting)
// flock and fcntl locks have different ownership and close semantics.
// Keep them in separate namespaces inside the table.
IsFlock bool
}
// inodeLocks holds all locks for one inode plus a waiter queue for SetLkw.
type inodeLocks struct {
mu sync.Mutex
locks []lockRange // currently held locks, sorted by Start
waiters []*lockWaiter // blocked SetLkw callers
wakeRefs int // woken waiters still retrying on this inodeLocks
}
// lockWaiter represents a blocked SetLkw caller.
type lockWaiter struct {
requested lockRange // the lock this waiter is trying to acquire
ch chan struct{} // closed when the waiter should re-check
wakeRefHeld bool
}
// PosixLockTable is the per-mount POSIX lock manager.
type PosixLockTable struct {
mu sync.Mutex
inodes map[uint64]*inodeLocks
}
func NewPosixLockTable() *PosixLockTable {
return &PosixLockTable{
inodes: make(map[uint64]*inodeLocks),
}
}
// getOrCreateInodeLocks returns the lock state for an inode, creating it if needed.
func (plt *PosixLockTable) getOrCreateInodeLocks(inode uint64) *inodeLocks {
plt.mu.Lock()
defer plt.mu.Unlock()
il, ok := plt.inodes[inode]
if !ok {
il = &inodeLocks{}
plt.inodes[inode] = il
}
return il
}
// getInodeLocks returns the lock state for an inode, or nil if none exists.
func (plt *PosixLockTable) getInodeLocks(inode uint64) *inodeLocks {
plt.mu.Lock()
defer plt.mu.Unlock()
return plt.inodes[inode]
}
// maybeCleanupInode removes the inodeLocks entry if it has no locks, no waiters,
// and no woken waiters still retrying against this inodeLocks.
func (plt *PosixLockTable) maybeCleanupInode(inode uint64, il *inodeLocks) {
// Caller must NOT hold il.mu. We acquire both locks in the correct order.
plt.mu.Lock()
defer plt.mu.Unlock()
il.mu.Lock()
defer il.mu.Unlock()
if len(il.locks) == 0 && len(il.waiters) == 0 && il.wakeRefs == 0 {
delete(plt.inodes, inode)
}
}
// rangesOverlap returns true if two inclusive ranges overlap.
func rangesOverlap(aStart, aEnd, bStart, bEnd uint64) bool {
return aStart <= bEnd && bStart <= aEnd
}
// findConflict returns the first lock that conflicts with the proposed lock.
// A conflict exists when ranges overlap, at least one is a write lock, and the owners differ.
func findConflict(locks []lockRange, proposed lockRange) (lockRange, bool) {
for _, h := range locks {
if h.IsFlock != proposed.IsFlock {
continue
}
if h.Owner == proposed.Owner {
continue
}
if !rangesOverlap(h.Start, h.End, proposed.Start, proposed.End) {
continue
}
if h.Typ == syscall.F_RDLCK && proposed.Typ == syscall.F_RDLCK {
continue
}
return h, true
}
return lockRange{}, false
}
// insertAndCoalesce inserts a lock for the given owner, replacing/splitting any
// existing same-owner locks that overlap. Adjacent same-type locks are merged.
// Caller must hold il.mu.
func insertAndCoalesce(il *inodeLocks, lk lockRange) {
owner := lk.Owner
var kept []lockRange
for _, h := range il.locks {
if h.Owner != owner || h.IsFlock != lk.IsFlock {
kept = append(kept, h)
continue
}
if !rangesOverlap(h.Start, h.End, lk.Start, lk.End) {
// Check for adjacency with same type for merging.
if h.Typ == lk.Typ && ((h.End < ^uint64(0) && h.End+1 == lk.Start) || (lk.End < ^uint64(0) && lk.End+1 == h.Start)) {
// Merge adjacent same-type lock into lk.
if h.Start < lk.Start {
lk.Start = h.Start
}
if h.End > lk.End {
lk.End = h.End
}
continue
}
kept = append(kept, h)
continue
}
// Overlapping same-owner lock.
if h.Typ == lk.Typ {
// Same type: absorb into lk (expand range).
if h.Start < lk.Start {
lk.Start = h.Start
}
if h.End > lk.End {
lk.End = h.End
}
continue
}
// Different type: truncate or split the existing lock.
if h.Start < lk.Start {
// Left portion survives.
left := h
left.End = lk.Start - 1
kept = append(kept, left)
}
if h.End > lk.End {
// Right portion survives.
right := h
right.Start = lk.End + 1
kept = append(kept, right)
}
}
kept = append(kept, lk)
sort.Slice(kept, func(i, j int) bool {
return kept[i].Start < kept[j].Start
})
il.locks = kept
}
// removeLocks removes or splits matching locks in the given range.
// Caller must hold il.mu.
func removeLocks(il *inodeLocks, matches func(lockRange) bool, start, end uint64) {
var kept []lockRange
for _, h := range il.locks {
if !matches(h) || !rangesOverlap(h.Start, h.End, start, end) {
kept = append(kept, h)
continue
}
// h overlaps the unlock range.
if h.Start < start {
// Left portion survives.
left := h
left.End = start - 1
kept = append(kept, left)
}
if h.End > end {
// Right portion survives.
right := h
right.Start = end + 1
kept = append(kept, right)
}
// If fully contained, it's simply dropped.
}
il.locks = kept
}
func (plt *PosixLockTable) releaseMatching(inode uint64, matches func(lockRange) bool) {
il := plt.getInodeLocks(inode)
if il == nil {
return
}
il.mu.Lock()
removeLocks(il, matches, 0, math.MaxUint64)
wakeEligibleWaiters(il)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
}
// releaseWakeRef drops the temporary reference that keeps inodeLocks live while
// a woken waiter retries its SetLkw acquisition.
func releaseWakeRef(il *inodeLocks, waiter *lockWaiter) {
if waiter == nil || !waiter.wakeRefHeld {
return
}
waiter.wakeRefHeld = false
il.wakeRefs--
}
// wakeEligibleWaiters selectively wakes blocked SetLkw callers that can now
// succeed given the current lock state. Waiters whose requests still conflict
// with held locks remain in the queue, avoiding a thundering herd.
// Caller must hold il.mu.
func wakeEligibleWaiters(il *inodeLocks) {
remaining := il.waiters[:0]
for _, w := range il.waiters {
if _, conflicted := findConflict(il.locks, w.requested); !conflicted {
w.wakeRefHeld = true
il.wakeRefs++
close(w.ch)
} else {
remaining = append(remaining, w)
}
}
il.waiters = remaining
}
// removeWaiter removes a specific waiter from the list.
// Caller must hold il.mu.
func removeWaiter(il *inodeLocks, w *lockWaiter) {
for i, existing := range il.waiters {
if existing == w {
il.waiters = append(il.waiters[:i], il.waiters[i+1:]...)
return
}
}
}
// GetLk checks for a conflicting lock. If found, it populates out with the
// conflict details. If no conflict, out.Typ is set to F_UNLCK.
func (plt *PosixLockTable) GetLk(inode uint64, proposed lockRange, out *fuse.LkOut) {
il := plt.getInodeLocks(inode)
if il == nil {
out.Lk.Typ = syscall.F_UNLCK
return
}
il.mu.Lock()
conflict, found := findConflict(il.locks, proposed)
il.mu.Unlock()
if found {
out.Lk.Start = conflict.Start
out.Lk.End = conflict.End
out.Lk.Typ = conflict.Typ
out.Lk.Pid = conflict.Pid
} else {
out.Lk.Typ = syscall.F_UNLCK
}
}
// SetLk attempts a non-blocking lock or unlock.
// For unlock (F_UNLCK): removes locks in the given range for the owner.
// For lock: returns fuse.EAGAIN if a conflict exists, fuse.OK on success.
func (plt *PosixLockTable) SetLk(inode uint64, lk lockRange) fuse.Status {
if lk.Typ == syscall.F_UNLCK {
il := plt.getInodeLocks(inode)
if il == nil {
return fuse.OK
}
il.mu.Lock()
removeLocks(il, func(existing lockRange) bool {
return existing.Owner == lk.Owner && existing.IsFlock == lk.IsFlock
}, lk.Start, lk.End)
wakeEligibleWaiters(il)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
return fuse.OK
}
il := plt.getOrCreateInodeLocks(inode)
il.mu.Lock()
if _, found := findConflict(il.locks, lk); found {
il.mu.Unlock()
return fuse.EAGAIN
}
insertAndCoalesce(il, lk)
il.mu.Unlock()
return fuse.OK
}
// SetLkw attempts a blocking lock. It waits until the lock can be acquired
// or the cancel channel is closed.
func (plt *PosixLockTable) SetLkw(inode uint64, lk lockRange, cancel <-chan struct{}) fuse.Status {
if lk.Typ == syscall.F_UNLCK {
return plt.SetLk(inode, lk)
}
il := plt.getOrCreateInodeLocks(inode)
var waiter *lockWaiter
for {
il.mu.Lock()
releaseWakeRef(il, waiter)
if _, found := findConflict(il.locks, lk); !found {
insertAndCoalesce(il, lk)
il.mu.Unlock()
return fuse.OK
}
// Register waiter with the requested lock details for selective waking.
waiter = &lockWaiter{requested: lk, ch: make(chan struct{})}
il.waiters = append(il.waiters, waiter)
il.mu.Unlock()
// Block until woken or cancelled.
select {
case <-waiter.ch:
// Woken — retry.
continue
case <-cancel:
// Request cancelled.
il.mu.Lock()
releaseWakeRef(il, waiter)
removeWaiter(il, waiter)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
return fuse.EINTR
}
}
}
// ReleaseOwner removes all locks held by the given owner on the given inode.
// Used for same-owner cleanup in tests and lock-table operations.
func (plt *PosixLockTable) ReleaseOwner(inode uint64, owner uint64) {
plt.releaseMatching(inode, func(lk lockRange) bool {
return lk.Owner == owner
})
}
// ReleaseFlockOwner removes flock locks for a released file description.
// FUSE only provides LockOwner on RELEASE when FUSE_RELEASE_FLOCK_UNLOCK is set.
func (plt *PosixLockTable) ReleaseFlockOwner(inode uint64, owner uint64) {
plt.releaseMatching(inode, func(lk lockRange) bool {
return lk.IsFlock && lk.Owner == owner
})
}
// ReleasePosixOwner removes POSIX fcntl locks for a closing lock owner.
// FUSE passes the closing fi->owner on FLUSH, which is the correct close-time
// identity for POSIX byte-range lock cleanup.
func (plt *PosixLockTable) ReleasePosixOwner(inode uint64, owner uint64) {
plt.releaseMatching(inode, func(lk lockRange) bool {
return !lk.IsFlock && lk.Owner == owner
})
}
+617
View File
@@ -0,0 +1,617 @@
package mount
import (
"math"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
)
func TestNonOverlappingLocksFromDifferentOwners(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
s1 := plt.SetLk(inode, lockRange{Start: 0, End: 49, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected OK, got %v", s1)
}
s2 := plt.SetLk(inode, lockRange{Start: 50, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s2 != fuse.OK {
t.Fatalf("expected OK, got %v", s2)
}
}
func TestOverlappingReadLocksFromDifferentOwners(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
s1 := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected OK, got %v", s1)
}
s2 := plt.SetLk(inode, lockRange{Start: 50, End: 149, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s2 != fuse.OK {
t.Fatalf("expected OK, got %v", s2)
}
}
func TestOverlappingWriteReadConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 50, End: 149, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s != fuse.EAGAIN {
t.Fatalf("expected EAGAIN, got %v", s)
}
}
func TestOverlappingWriteWriteConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 50, End: 149, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s != fuse.EAGAIN {
t.Fatalf("expected EAGAIN, got %v", s)
}
}
func TestSameOwnerUpgradeReadToWrite(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK for same-owner upgrade, got %v", s)
}
// Verify the lock is now a write lock.
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected conflicting write lock, got type %d", out.Lk.Typ)
}
}
func TestSameOwnerDowngradeWriteToRead(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK for same-owner downgrade, got %v", s)
}
// Another owner should now be able to get a read lock.
s2 := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s2 != fuse.OK {
t.Fatalf("expected OK for shared read lock, got %v", s2)
}
}
func TestLockCoalescing(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 9, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 10, End: 19, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
ownerLocks := 0
for _, lk := range il.locks {
if lk.Owner == 1 {
ownerLocks++
if lk.Start != 0 || lk.End != 19 {
t.Errorf("expected coalesced lock [0,19], got [%d,%d]", lk.Start, lk.End)
}
}
}
il.mu.Unlock()
if ownerLocks != 1 {
t.Fatalf("expected 1 coalesced lock, got %d", ownerLocks)
}
}
func TestLockSplitting(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
// Unlock the middle portion.
plt.SetLk(inode, lockRange{Start: 40, End: 59, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
ownerLocks := 0
for _, lk := range il.locks {
if lk.Owner == 1 {
ownerLocks++
}
}
if ownerLocks != 2 {
il.mu.Unlock()
t.Fatalf("expected 2 locks after split, got %d", ownerLocks)
}
// Check the ranges.
if il.locks[0].Start != 0 || il.locks[0].End != 39 {
t.Errorf("expected left lock [0,39], got [%d,%d]", il.locks[0].Start, il.locks[0].End)
}
if il.locks[1].Start != 60 || il.locks[1].End != 99 {
t.Errorf("expected right lock [60,99], got [%d,%d]", il.locks[1].Start, il.locks[1].End)
}
il.mu.Unlock()
}
func TestGetLkConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 10, End: 50, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 30, End: 70, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected conflicting write lock, got type %d", out.Lk.Typ)
}
if out.Lk.Pid != 10 {
t.Fatalf("expected holder PID 10, got %d", out.Lk.Pid)
}
if out.Lk.Start != 10 || out.Lk.End != 50 {
t.Fatalf("expected conflict [10,50], got [%d,%d]", out.Lk.Start, out.Lk.End)
}
}
func TestGetLkNoConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 10, End: 50, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 30, End: 70, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("expected F_UNLCK (no conflict), got type %d", out.Lk.Typ)
}
}
func TestGetLkSameOwnerNoConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("same owner should not conflict with itself, got type %d", out.Lk.Typ)
}
}
func TestReleaseOwner(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 49, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 50, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
plt.ReleaseOwner(inode, 1)
// Owner 1's locks should be gone.
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("expected no conflict after ReleaseOwner, got type %d", out.Lk.Typ)
}
// Owner 2's lock should still exist.
plt.GetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, &out)
if out.Lk.Typ != syscall.F_RDLCK {
t.Fatalf("expected owner 2's read lock to remain, got type %d", out.Lk.Typ)
}
}
func TestDifferentLockKindsDoNotConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
s1 := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected POSIX lock OK, got %v", s1)
}
s2 := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20, IsFlock: true})
if s2 != fuse.OK {
t.Fatalf("expected flock lock OK in separate namespace, got %v", s2)
}
}
func TestReleasePosixOwnerReleasesPosixLocksAndWakesWaiters(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
done := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
done <- plt.SetLkw(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
}()
time.Sleep(50 * time.Millisecond)
plt.ReleasePosixOwner(inode, 1)
select {
case s := <-done:
if s != fuse.OK {
t.Fatalf("expected OK after ReleasePosixOwner, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after ReleasePosixOwner")
}
}
func TestReleasePosixOwnerDoesNotReleaseFlockLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10, IsFlock: true})
plt.ReleasePosixOwner(inode, 1)
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20, IsFlock: true}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected flock lock to remain after ReleasePosixOwner, got type %d", out.Lk.Typ)
}
}
func TestWakeEligibleWaitersKeepsInodeUntilWakeRefReleased(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
il := plt.getOrCreateInodeLocks(inode)
waiter := &lockWaiter{
requested: lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20},
ch: make(chan struct{}),
}
il.mu.Lock()
il.waiters = append(il.waiters, waiter)
il.mu.Unlock()
plt.releaseMatching(inode, func(lockRange) bool { return false })
select {
case <-waiter.ch:
// Expected.
default:
t.Fatal("expected waiter to be woken")
}
plt.mu.Lock()
_, exists := plt.inodes[inode]
plt.mu.Unlock()
if !exists {
t.Fatal("inodeLocks should remain while a woken waiter still holds a wake ref")
}
il.mu.Lock()
releaseWakeRef(il, waiter)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
plt.mu.Lock()
_, exists = plt.inodes[inode]
plt.mu.Unlock()
if exists {
t.Fatal("inodeLocks should be cleaned up after the final wake ref is released")
}
}
func TestReleaseFlockOwnerDoesNotReleasePosixLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 10, IsFlock: true})
plt.ReleaseFlockOwner(inode, 2)
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected POSIX lock to remain after ReleaseFlockOwner, got type %d", out.Lk.Typ)
}
plt.GetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 4, Pid: 40, IsFlock: true}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("expected flock lock to be removed after ReleaseFlockOwner, got type %d", out.Lk.Typ)
}
}
func TestReleaseOwnerWakesWaiters(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
done := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 50, End: 60, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
done <- s
}()
// Give the goroutine time to block.
time.Sleep(50 * time.Millisecond)
plt.ReleaseOwner(inode, 1)
select {
case s := <-done:
if s != fuse.OK {
t.Fatalf("expected OK after ReleaseOwner woke waiter, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after ReleaseOwner")
}
}
func TestSetLkwBlocksAndSucceeds(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
done := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
done <- s
}()
// Give the goroutine time to block.
time.Sleep(50 * time.Millisecond)
// Release the conflicting lock.
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
select {
case s := <-done:
if s != fuse.OK {
t.Fatalf("expected OK, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after conflicting lock was released")
}
}
func TestSetLkwCancellation(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
cancel := make(chan struct{})
done := make(chan fuse.Status, 1)
go func() {
s := plt.SetLkw(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
done <- s
}()
// Give the goroutine time to block.
time.Sleep(50 * time.Millisecond)
close(cancel)
select {
case s := <-done:
if s != fuse.EINTR {
t.Fatalf("expected EINTR on cancel, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after cancel")
}
}
func TestWholeFileLock(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Simulate flock() — whole-file exclusive lock.
s1 := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected OK, got %v", s1)
}
// Second owner should be blocked.
s2 := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s2 != fuse.EAGAIN {
t.Fatalf("expected EAGAIN, got %v", s2)
}
// Even a partial overlap should fail.
s3 := plt.SetLk(inode, lockRange{Start: 100, End: 200, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s3 != fuse.EAGAIN {
t.Fatalf("expected EAGAIN for partial overlap with whole-file lock, got %v", s3)
}
}
func TestUnlockNoExistingLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Unlock on an inode with no locks should succeed silently.
s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK for unlock with no existing locks, got %v", s)
}
}
func TestMultipleInodesIndependent(t *testing.T) {
plt := NewPosixLockTable()
// Write lock on inode 1 should not affect inode 2.
plt.SetLk(1, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(2, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s != fuse.OK {
t.Fatalf("locks on different inodes should be independent, got %v", s)
}
}
func TestMemoryCleanup(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.ReleaseOwner(inode, 1)
plt.mu.Lock()
_, exists := plt.inodes[inode]
plt.mu.Unlock()
if exists {
t.Fatal("expected inode entry to be cleaned up after all locks released")
}
}
func TestSelectiveWaking(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Owner 1 holds write lock on [0, 99], owner 2 holds write lock on [200, 299].
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
// Owner 3 waits for [50, 60] (blocked by owner 1).
done3 := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 50, End: 60, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, cancel)
done3 <- s
}()
// Owner 4 waits for [250, 260] (blocked by owner 2).
done4 := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 250, End: 260, Typ: syscall.F_WRLCK, Owner: 4, Pid: 40}, cancel)
done4 <- s
}()
time.Sleep(50 * time.Millisecond)
// Release owner 1's lock. Only owner 3 should be woken; owner 4 is still blocked.
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
select {
case s := <-done3:
if s != fuse.OK {
t.Fatalf("expected OK for owner 3, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("owner 3 was not woken after owner 1 released")
}
// Owner 4 should still be blocked.
select {
case s := <-done4:
t.Fatalf("owner 4 should still be blocked, but got %v", s)
case <-time.After(100 * time.Millisecond):
// Expected — still blocked.
}
// Now release owner 2's lock. Owner 4 should wake.
plt.SetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_UNLCK, Owner: 2, Pid: 20})
select {
case s := <-done4:
if s != fuse.OK {
t.Fatalf("expected OK for owner 4, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("owner 4 was not woken after owner 2 released")
}
}
func TestSameOwnerReplaceDifferentType(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Lock [0, 99] as write.
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
// Replace middle portion [30, 60] with read lock.
plt.SetLk(inode, lockRange{Start: 30, End: 60, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
defer il.mu.Unlock()
// Should have 3 locks: write [0,29], read [30,60], write [61,99].
if len(il.locks) != 3 {
t.Fatalf("expected 3 locks after partial type change, got %d", len(il.locks))
}
if il.locks[0].Typ != syscall.F_WRLCK || il.locks[0].Start != 0 || il.locks[0].End != 29 {
t.Errorf("expected write [0,29], got type=%d [%d,%d]", il.locks[0].Typ, il.locks[0].Start, il.locks[0].End)
}
if il.locks[1].Typ != syscall.F_RDLCK || il.locks[1].Start != 30 || il.locks[1].End != 60 {
t.Errorf("expected read [30,60], got type=%d [%d,%d]", il.locks[1].Typ, il.locks[1].Start, il.locks[1].End)
}
if il.locks[2].Typ != syscall.F_WRLCK || il.locks[2].Start != 61 || il.locks[2].End != 99 {
t.Errorf("expected write [61,99], got type=%d [%d,%d]", il.locks[2].Typ, il.locks[2].Start, il.locks[2].End)
}
}
func TestNonAdjacentRangesNotCoalesced(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Lock [5, MaxUint64] then [0, 2] — gap at [3,4] must prevent coalescing.
plt.SetLk(inode, lockRange{Start: 5, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 0, End: 2, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK, got %v", s)
}
il := plt.getInodeLocks(inode)
il.mu.Lock()
defer il.mu.Unlock()
if len(il.locks) != 2 {
t.Fatalf("expected 2 separate locks (gap [3,4] prevents coalescing), got %d", len(il.locks))
}
if il.locks[0].Start != 0 || il.locks[0].End != 2 {
t.Errorf("expected first lock [0,2], got [%d,%d]", il.locks[0].Start, il.locks[0].End)
}
if il.locks[1].Start != 5 || il.locks[1].End != math.MaxUint64 {
t.Errorf("expected second lock [5,MaxUint64], got [%d,%d]", il.locks[1].Start, il.locks[1].End)
}
}
func TestAdjacencyNoOverflowAtMaxUint64(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Lock to EOF (End = MaxUint64), then lock [0, 0] same type.
// Without the overflow guard, MaxUint64+1 wraps to 0, falsely merging.
plt.SetLk(inode, lockRange{Start: 100, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 0, End: 0, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
defer il.mu.Unlock()
// Should remain 2 separate locks, not merged.
ownerLocks := 0
for _, lk := range il.locks {
if lk.Owner == 1 {
ownerLocks++
}
}
if ownerLocks != 2 {
t.Fatalf("expected 2 separate locks (no overflow merge), got %d", ownerLocks)
}
}
+71 -27
View File
@@ -19,6 +19,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mount_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/chunk_cache"
@@ -30,27 +31,29 @@ import (
)
type Option struct {
filerIndex int32 // align memory for atomic read/write
FilerAddresses []pb.ServerAddress
MountDirectory string
GrpcDialOption grpc.DialOption
FilerMountRootPath string
Collection string
Replication string
TtlSec int32
DiskType types.DiskType
ChunkSizeLimit int64
ConcurrentWriters int
ConcurrentReaders int
CacheDirForRead string
CacheSizeMBForRead int64
CacheDirForWrite string
CacheMetaTTlSec int
DataCenter string
Umask os.FileMode
Quota int64
DisableXAttr bool
IsMacOs bool
filerIndex int32 // align memory for atomic read/write
FilerAddresses []pb.ServerAddress
MountDirectory string
GrpcDialOption grpc.DialOption
FilerSigningKey security.SigningKey
FilerSigningExpiresAfterSec int
FilerMountRootPath string
Collection string
Replication string
TtlSec int32
DiskType types.DiskType
ChunkSizeLimit int64
ConcurrentWriters int
ConcurrentReaders int
CacheDirForRead string
CacheSizeMBForRead int64
CacheDirForWrite string
CacheMetaTTlSec int
DataCenter string
Umask os.FileMode
Quota int64
DisableXAttr bool
IsMacOs bool
MountUid uint32
MountGid uint32
@@ -106,6 +109,7 @@ type WFS struct {
fuseServer *fuse.Server
IsOverQuota bool
fhLockTable *util.LockTable[FileHandleId]
posixLocks *PosixLockTable
rdmaClient *RDMAMountClient
FilerConf *filer.FilerConf
filerClient *wdclient.FilerClient // Cached volume location client
@@ -115,15 +119,25 @@ type WFS struct {
dirHotThreshold int
dirIdleEvict time.Duration
// asyncFlushWg tracks pending background flush goroutines for writebackCache mode.
// asyncFlushWg tracks pending background flush work items for writebackCache mode.
// Must be waited on before unmount cleanup to prevent data loss.
asyncFlushWg sync.WaitGroup
// asyncFlushCh is a bounded work queue for background flush operations.
// A fixed pool of worker goroutines processes items from this channel,
// preventing resource exhaustion from unbounded goroutine creation.
asyncFlushCh chan *asyncFlushItem
// pendingAsyncFlush tracks in-flight async flush goroutines by inode.
// AcquireHandle checks this to wait for a pending flush before reopening
// the same inode, preventing stale metadata from overwriting the async flush.
pendingAsyncFlushMu sync.Mutex
pendingAsyncFlush map[uint64]chan struct{}
// streamMutate is the multiplexed streaming gRPC connection for all filer
// mutations (create, update, delete, rename). All mutations go through one
// ordered stream to prevent cross-operation reordering.
streamMutate *streamMutateMux
}
const (
@@ -173,11 +187,12 @@ func NewSeaweedFileSystem(option *Option) *WFS {
dhMap: NewDirectoryHandleToInode(),
filerClient: filerClient, // nil for proxy mode, initialized for direct access
pendingAsyncFlush: make(map[uint64]chan struct{}),
fhLockTable: util.NewLockTable[FileHandleId](),
refreshingDirs: make(map[util.FullPath]struct{}),
dirHotWindow: dirHotWindow,
dirHotThreshold: dirHotThreshold,
dirIdleEvict: dirIdleEvict,
fhLockTable: util.NewLockTable[FileHandleId](),
posixLocks: NewPosixLockTable(),
refreshingDirs: make(map[util.FullPath]struct{}),
dirHotWindow: dirHotWindow,
dirHotThreshold: dirHotThreshold,
dirIdleEvict: dirIdleEvict,
}
wfs.option.filerIndex = int32(rand.IntN(len(option.FilerAddresses)))
@@ -272,6 +287,14 @@ func NewSeaweedFileSystem(option *Option) *WFS {
wfs.concurrentWriters = util.NewLimitedConcurrentExecutor(wfs.option.ConcurrentWriters)
wfs.concurrentCopiersSem = make(chan struct{}, wfs.option.ConcurrentWriters)
}
if wfs.option.WritebackCache {
numWorkers := wfs.option.ConcurrentWriters
if numWorkers <= 0 {
numWorkers = 128
}
wfs.startAsyncFlushWorkers(numWorkers)
}
wfs.streamMutate = newStreamMutateMux(wfs)
wfs.copyBufferPool.New = func() any {
return make([]byte, option.ChunkSizeLimit)
}
@@ -387,6 +410,27 @@ func (wfs *WFS) lookupEntry(fullpath util.FullPath) (*filer.Entry, fuse.Status)
entry, err := filer_pb.GetEntry(context.Background(), wfs, fullpath)
if err != nil {
if err == filer_pb.ErrNotFound {
// The entry may exist in the local store from a deferred create
// (deferFilerCreate=true) that hasn't been flushed yet. Only trust
// the local store when an open file handle or pending async flush
// confirms the entry is genuinely local-only; otherwise a stale
// cache hit could resurrect a deleted/renamed entry.
if inode, inodeFound := wfs.inodeToPath.GetInode(fullpath); inodeFound {
hasDirtyHandle := false
if fh, fhFound := wfs.fhMap.FindFileHandle(inode); fhFound && fh.dirtyMetadata {
hasDirtyHandle = true
}
wfs.pendingAsyncFlushMu.Lock()
_, hasPendingFlush := wfs.pendingAsyncFlush[inode]
wfs.pendingAsyncFlushMu.Unlock()
if hasDirtyHandle || hasPendingFlush {
if localEntry, localErr := wfs.metaCache.FindEntry(context.Background(), fullpath); localErr == nil && localEntry != nil {
glog.V(4).Infof("lookupEntry found deferred entry in local cache %s", fullpath)
return localEntry, fuse.OK
}
}
}
glog.V(4).Infof("lookupEntry not found %s", fullpath)
return nil, fuse.ENOENT
}
+105
View File
@@ -0,0 +1,105 @@
package mount
import (
"os/user"
"strconv"
"syscall"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
var lookupSupplementaryGroupIDs = func(callerUid uint32) ([]string, error) {
u, err := user.LookupId(strconv.Itoa(int(callerUid)))
if err != nil {
glog.Warningf("hasAccess: user.LookupId for uid %d failed: %v", callerUid, err)
return nil, err
}
groupIDs, err := u.GroupIds()
if err != nil {
glog.Warningf("hasAccess: u.GroupIds for uid %d failed: %v", callerUid, err)
return nil, err
}
return groupIDs, nil
}
/**
* Check file access permissions
*
* This will be called for the access() system call. If the
* 'default_permissions' mount option is given, this method is not
* called.
*
* This method is not called under Linux kernel versions 2.4.x
*/
func (wfs *WFS) Access(cancel <-chan struct{}, input *fuse.AccessIn) (code fuse.Status) {
_, _, entry, code := wfs.maybeReadEntry(input.NodeId)
if code != fuse.OK {
return code
}
if entry == nil || entry.Attributes == nil {
return fuse.EIO
}
// Map entry uid/gid from filer-space to local-space so the permission
// check compares like with like (caller uid/gid from FUSE are local).
fileUid, fileGid := entry.Attributes.Uid, entry.Attributes.Gid
if wfs.option.UidGidMapper != nil {
fileUid, fileGid = wfs.option.UidGidMapper.FilerToLocal(fileUid, fileGid)
}
if hasAccess(input.Uid, input.Gid, fileUid, fileGid, entry.Attributes.FileMode, input.Mask) {
return fuse.OK
}
return fuse.EACCES
}
func hasAccess(callerUid, callerGid, fileUid, fileGid uint32, perm uint32, mask uint32) bool {
mask &= fuse.R_OK | fuse.W_OK | fuse.X_OK
if mask == 0 {
return true
}
if callerUid == 0 {
return mask&fuse.X_OK == 0 || perm&0o111 != 0
}
if callerUid == fileUid {
return (perm>>6)&mask == mask
}
isMember := callerGid == fileGid
if !isMember {
groupIDs, err := lookupSupplementaryGroupIDs(callerUid)
if err != nil {
// Cannot determine group membership; require both group and
// other permission classes to satisfy the mask so we never
// overgrant when the lookup fails.
groupMatch := ((perm >> 3) & mask) == mask
otherMatch := (perm & mask) == mask
return groupMatch && otherMatch
}
fileGidStr := strconv.Itoa(int(fileGid))
for _, gidStr := range groupIDs {
if gidStr == fileGidStr {
isMember = true
break
}
}
}
if isMember {
return (perm>>3)&mask == mask
}
return (perm & mask) == mask
}
// openFlagsToAccessMask converts open(2) flags to an access permission mask.
func openFlagsToAccessMask(flags uint32) uint32 {
switch flags & uint32(syscall.O_ACCMODE) {
case syscall.O_WRONLY:
return fuse.W_OK
case syscall.O_RDWR:
return fuse.R_OK | fuse.W_OK
default: // O_RDONLY
return fuse.R_OK
}
}
+75 -30
View File
@@ -8,17 +8,52 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
)
const asyncFlushMetadataRetries = 3
// asyncFlushItem holds the data needed for a background flush work item.
type asyncFlushItem struct {
fh *FileHandle
done chan struct{}
}
// completeAsyncFlush is called in a background goroutine when a file handle
// with pending async flush work is released. It performs the deferred data
// upload and metadata flush that was skipped in doFlush() for writebackCache mode.
// startAsyncFlushWorkers launches a fixed pool of goroutines that process
// background flush work items from asyncFlushCh. This bounds the number of
// concurrent flush operations to prevent resource exhaustion (connections,
// goroutines) when many files are closed rapidly (e.g., cp -r with writebackCache).
func (wfs *WFS) startAsyncFlushWorkers(numWorkers int) {
wfs.asyncFlushCh = make(chan *asyncFlushItem, numWorkers*4)
for i := 0; i < numWorkers; i++ {
go wfs.asyncFlushWorker()
}
}
func (wfs *WFS) asyncFlushWorker() {
for item := range wfs.asyncFlushCh {
wfs.processAsyncFlushItem(item)
}
}
func (wfs *WFS) processAsyncFlushItem(item *asyncFlushItem) {
defer wfs.asyncFlushWg.Done()
defer func() {
// Remove from fhMap first (so AcquireFileHandle creates a fresh handle).
wfs.fhMap.RemoveFileHandle(item.fh.fh, item.fh.inode)
// Then signal completion (unblocks waitForPendingAsyncFlush).
close(item.done)
wfs.pendingAsyncFlushMu.Lock()
delete(wfs.pendingAsyncFlush, item.fh.inode)
wfs.pendingAsyncFlushMu.Unlock()
}()
wfs.completeAsyncFlush(item.fh)
}
// completeAsyncFlush performs the deferred data upload and metadata flush
// that was skipped in doFlush() for writebackCache mode.
//
// This enables close() to return immediately for small file workloads (e.g., rsync),
// while the actual I/O happens concurrently in the background.
//
// The caller (submitAsyncFlush) owns asyncFlushWg and the per-inode done channel.
func (wfs *WFS) completeAsyncFlush(fh *FileHandle) {
glog.V(4).Infof("completeAsyncFlush inode %d fh %d saved=%s/%s dirtyMetadata=%v isDeleted=%v isRenamed=%v",
fh.inode, fh.fh, fh.savedDir, fh.savedName, fh.dirtyMetadata, fh.isDeleted, fh.isRenamed)
// Phase 1: Flush dirty pages — seals writable chunks, uploads to volume servers, and waits.
// The underlying UploadWithRetry already retries transient HTTP/gRPC errors internally,
// so a failure here indicates a persistent issue; the chunk data has been freed.
@@ -33,20 +68,31 @@ func (wfs *WFS) completeAsyncFlush(fh *FileHandle) {
// handle. In that case the filer entry is already gone and
// flushing would recreate it. The uploaded chunks become orphans
// and are cleaned up by volume.fsck.
if fh.isDeleted {
glog.V(3).Infof("completeAsyncFlush inode %d: file was unlinked, skipping metadata flush", fh.inode)
if fh.isDeleted || fh.isRenamed {
if fh.isDeleted {
glog.V(3).Infof("completeAsyncFlush inode %d: file was unlinked, skipping metadata flush", fh.inode)
} else {
glog.V(3).Infof("completeAsyncFlush inode %d: file was renamed, skipping old-path metadata flush (Rename handles it)", fh.inode)
}
} else if savedInode, found := wfs.inodeToPath.GetInode(util.FullPath(fh.savedDir).Child(fh.savedName)); !found || savedInode != fh.inode {
// The saved path no longer maps to this inode — the file was
// renamed (or deleted and recreated). Flushing metadata under
// the old path would re-insert a stale entry into the meta
// cache, breaking git's lock file protocol.
glog.V(3).Infof("completeAsyncFlush inode %d: saved path %s/%s no longer maps to this inode, skipping metadata flush",
fh.inode, fh.savedDir, fh.savedName)
} else {
// Resolve the current path for metadata flush.
//
// Try GetPath first — it reflects any rename that happened
// after close(). If the inode mapping is gone (Forget
// dropped it after the kernel's lookup count hit zero), fall
// back to the dir/name saved at doFlush time. Rename also
// updates the saved path, so the fallback is always current.
// back to the last path saved on the handle. Rename keeps
// that fallback current, so it is always the newest known path.
//
// Forget does NOT mean the file was deleted — it only means
// the kernel evicted its cache entry.
dir, name := fh.asyncFlushDir, fh.asyncFlushName
dir, name := fh.savedDir, fh.savedName
fileFullPath := util.FullPath(dir).Child(name)
if resolvedPath, status := wfs.inodeToPath.GetPath(fh.inode); status == fuse.OK {
@@ -68,29 +114,28 @@ func (wfs *WFS) completeAsyncFlush(fh *FileHandle) {
// with exponential backoff on transient errors. The chunk data is already on the
// volume servers at this point; only the filer metadata reference needs persisting.
func (wfs *WFS) flushMetadataWithRetry(fh *FileHandle, dir, name string, fileFullPath util.FullPath) {
for attempt := 0; attempt <= asyncFlushMetadataRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
glog.Warningf("completeAsyncFlush %s: retrying metadata flush (attempt %d/%d) after %v",
fileFullPath, attempt+1, asyncFlushMetadataRetries+1, backoff)
time.Sleep(backoff)
}
if err := wfs.flushMetadataToFiler(fh, dir, name, fh.asyncFlushUid, fh.asyncFlushGid); err != nil {
if attempt == asyncFlushMetadataRetries {
glog.Errorf("completeAsyncFlush %s: metadata flush failed after %d attempts: %v — "+
"chunks are uploaded but NOT referenced in filer metadata; "+
"they will appear as orphans in volume.fsck",
fileFullPath, asyncFlushMetadataRetries+1, err)
}
continue
}
return // success
err := retryMetadataFlush(func() error {
return wfs.flushMetadataToFiler(fh, dir, name, fh.asyncFlushUid, fh.asyncFlushGid)
}, func(nextAttempt, totalAttempts int, backoff time.Duration, err error) {
glog.Warningf("completeAsyncFlush %s: retrying metadata flush (attempt %d/%d) after %v: %v",
fileFullPath, nextAttempt, totalAttempts, backoff, err)
})
if err != nil {
glog.Errorf("completeAsyncFlush %s: metadata flush failed after %d attempts: %v - "+
"chunks are uploaded but NOT referenced in filer metadata; "+
"they will appear as orphans in volume.fsck",
fileFullPath, metadataFlushRetries+1, err)
}
}
// WaitForAsyncFlush waits for all pending background flush goroutines to complete.
// WaitForAsyncFlush waits for all pending background flush work items to complete.
// Called before unmount cleanup to ensure no data is lost.
func (wfs *WFS) WaitForAsyncFlush() {
wfs.asyncFlushWg.Wait()
if wfs.asyncFlushCh != nil {
close(wfs.asyncFlushCh)
}
if wfs.streamMutate != nil {
wfs.streamMutate.Close()
}
}
+31 -22
View File
@@ -49,25 +49,24 @@ func (wfs *WFS) Mkdir(cancel <-chan struct{}, in *fuse.MkdirIn, name string, out
entryFullPath := dirFullPath.Child(name)
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
wfs.mapPbIdFromLocalToFiler(newEntry)
// Defer restoring to local uid/gid AFTER the entry is sent to the filer
// but BEFORE outputPbEntry writes attributes to the kernel. We restore
// explicitly below instead of using defer so the kernel gets local values.
wfs.mapPbIdFromLocalToFiler(newEntry)
defer wfs.mapPbIdFromFilerToLocal(newEntry)
request := &filer_pb.CreateEntryRequest{
Directory: string(dirFullPath),
Entry: newEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
glog.V(1).Infof("mkdir: %v", request)
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
glog.V(0).Infof("mkdir %s: %v", entryFullPath, err)
return err
}
request := &filer_pb.CreateEntryRequest{
Directory: string(dirFullPath),
Entry: newEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
glog.V(1).Infof("mkdir: %v", request)
resp, err := wfs.streamCreateEntry(context.Background(), request)
if err != nil {
glog.V(0).Infof("mkdir %s: %v", entryFullPath, err)
} else {
event := resp.GetMetadataEvent()
if event == nil {
event = metadataCreateEvent(string(dirFullPath), newEntry)
@@ -77,16 +76,20 @@ func (wfs *WFS) Mkdir(cancel <-chan struct{}, in *fuse.MkdirIn, name string, out
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
}
wfs.inodeToPath.TouchDirectory(dirFullPath)
return nil
})
}
glog.V(3).Infof("mkdir %s: %v", entryFullPath, err)
if err != nil {
wfs.mapPbIdFromFilerToLocal(newEntry)
return fuse.EIO
}
// Map uid/gid back to local-space before writing attributes to the
// kernel. The kernel (especially macFUSE) caches these and uses them
// for subsequent permission checks on children.
wfs.mapPbIdFromFilerToLocal(newEntry)
inode := wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, true, false, 0, true)
wfs.outputPbEntry(out, inode, newEntry)
@@ -112,10 +115,16 @@ func (wfs *WFS) Rmdir(cancel <-chan struct{}, header *fuse.InHeader, name string
entryFullPath := dirFullPath.Child(name)
glog.V(3).Infof("remove directory: %v", entryFullPath)
ignoreRecursiveErr := true // ignore recursion error since the OS should manage it
resp, err := filer_pb.RemoveWithResponse(context.Background(), wfs, string(dirFullPath), name, true, false, ignoreRecursiveErr, false, []int32{wfs.signature})
deleteReq := &filer_pb.DeleteEntryRequest{
Directory: string(dirFullPath),
Name: name,
IsDeleteData: true,
IgnoreRecursiveError: true, // ignore recursion error since the OS should manage it
Signatures: []int32{wfs.signature},
}
resp, err := wfs.streamDeleteEntry(context.Background(), deleteReq)
if err != nil {
glog.V(0).Infof("remove %s: %v", entryFullPath, err)
glog.V(1).Infof("remove %s: %v", entryFullPath, err)
if strings.Contains(err.Error(), filer.MsgFailDelNonEmptyFolder) {
return fuse.Status(syscall.ENOTEMPTY)
}
+355
View File
@@ -1,15 +1,63 @@
package mount
import (
"bytes"
"context"
"fmt"
"io"
"math"
"net/http"
"net/url"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
request_id "github.com/seaweedfs/seaweedfs/weed/util/request_id"
)
type serverSideWholeFileCopyOutcome uint8
const (
serverSideWholeFileCopyNotCommitted serverSideWholeFileCopyOutcome = iota
serverSideWholeFileCopyCommitted
serverSideWholeFileCopyAmbiguous
)
type wholeFileServerCopyRequest struct {
srcPath util.FullPath
dstPath util.FullPath
sourceSize int64
srcInode uint64
srcMtime int64
dstInode uint64
dstMtime int64
dstSize int64
sourceMime string
sourceMd5 []byte
copyRequestID string
}
// performServerSideWholeFileCopy is a package-level seam so tests can override
// the filer call without standing up an HTTP endpoint.
var performServerSideWholeFileCopy = func(cancel <-chan struct{}, wfs *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, serverSideWholeFileCopyOutcome, error) {
return wfs.copyEntryViaFiler(cancel, copyRequest)
}
// filerCopyRequestTimeout bounds the mount->filer POST so a stalled copy does
// not block copy_file_range workers indefinitely.
const filerCopyRequestTimeout = 60 * time.Second
// filerCopyReadbackTimeout gives the follow-up metadata reload a fresh deadline
// after the filer already accepted the copy request.
const filerCopyReadbackTimeout = 15 * time.Second
// CopyFileRange copies data from one file to another from and to specified offsets.
//
// See https://man7.org/linux/man-pages/man2/copy_file_range.2.html
@@ -70,6 +118,10 @@ func (wfs *WFS) CopyFileRange(cancel <-chan struct{}, in *fuse.CopyFileRangeIn)
in.OffOut, in.OffOut+in.Len,
)
if written, handled, status := wfs.tryServerSideWholeFileCopy(cancel, in, fhIn, fhOut); handled {
return written, status
}
// Concurrent copy operations could allocate too much memory, so we want to
// throttle our concurrency, scaling with the number of writers the mount
// was configured with.
@@ -155,3 +207,306 @@ func (wfs *WFS) CopyFileRange(cancel <-chan struct{}, in *fuse.CopyFileRangeIn)
written = uint32(totalCopied)
return written, fuse.OK
}
func (wfs *WFS) tryServerSideWholeFileCopy(cancel <-chan struct{}, in *fuse.CopyFileRangeIn, fhIn, fhOut *FileHandle) (written uint32, handled bool, code fuse.Status) {
copyRequest, ok := wholeFileServerCopyCandidate(fhIn, fhOut, in)
if !ok {
return 0, false, fuse.OK
}
glog.V(1).Infof("CopyFileRange server-side copy %s => %s (%d bytes)", copyRequest.srcPath, copyRequest.dstPath, copyRequest.sourceSize)
entry, outcome, err := performServerSideWholeFileCopy(cancel, wfs, copyRequest)
switch outcome {
case serverSideWholeFileCopyCommitted:
if err != nil {
glog.Warningf("CopyFileRange server-side copy %s => %s committed but local refresh failed: %v", copyRequest.srcPath, copyRequest.dstPath, err)
} else {
glog.V(1).Infof("CopyFileRange server-side copy %s => %s completed (%d bytes)", copyRequest.srcPath, copyRequest.dstPath, copyRequest.sourceSize)
}
wfs.applyServerSideWholeFileCopyResult(fhIn, fhOut, copyRequest.dstPath, entry, copyRequest.sourceSize)
return uint32(copyRequest.sourceSize), true, fuse.OK
case serverSideWholeFileCopyAmbiguous:
glog.Warningf("CopyFileRange server-side copy %s => %s outcome ambiguous: %v", copyRequest.srcPath, copyRequest.dstPath, err)
return 0, true, fuse.EIO
default:
glog.V(0).Infof("CopyFileRange server-side copy %s => %s fallback to chunk copy: %v", copyRequest.srcPath, copyRequest.dstPath, err)
return 0, false, fuse.OK
}
}
func (wfs *WFS) applyServerSideWholeFileCopyResult(fhIn, fhOut *FileHandle, dstPath util.FullPath, entry *filer_pb.Entry, sourceSize int64) {
if entry == nil {
entry = synthesizeLocalEntryForServerSideWholeFileCopy(fhIn, fhOut, sourceSize)
}
if entry == nil {
glog.Warningf("CopyFileRange server-side copy %s left no local entry to apply", dstPath)
return
}
fhOut.SetEntry(entry)
fhOut.RememberPath(dstPath)
if entry.Attributes != nil {
fhOut.contentType = entry.Attributes.Mime
}
fhOut.dirtyMetadata = false
wfs.updateServerSideWholeFileCopyMetaCache(dstPath, entry)
wfs.invalidateCopyDestinationCache(fhOut.inode, dstPath)
}
func (wfs *WFS) updateServerSideWholeFileCopyMetaCache(dstPath util.FullPath, entry *filer_pb.Entry) {
if wfs.metaCache == nil || entry == nil {
return
}
dir, _ := dstPath.DirAndName()
event := metadataUpdateEvent(dir, entry)
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("CopyFileRange metadata update %s: %v", dstPath, applyErr)
wfs.markDirectoryReadThrough(util.FullPath(dir))
}
}
func synthesizeLocalEntryForServerSideWholeFileCopy(fhIn, fhOut *FileHandle, sourceSize int64) *filer_pb.Entry {
dstEntry := fhOut.GetEntry().GetEntry()
if dstEntry == nil {
return nil
}
localEntry := proto.Clone(dstEntry).(*filer_pb.Entry)
if localEntry.Attributes == nil {
localEntry.Attributes = &filer_pb.FuseAttributes{}
}
if srcEntry := fhIn.GetEntry().GetEntry(); srcEntry != nil {
srcEntryCopy := proto.Clone(srcEntry).(*filer_pb.Entry)
localEntry.Content = srcEntryCopy.Content
localEntry.Chunks = srcEntryCopy.Chunks
if srcEntryCopy.Attributes != nil {
localEntry.Attributes.Mime = srcEntryCopy.Attributes.Mime
localEntry.Attributes.Md5 = srcEntryCopy.Attributes.Md5
}
}
localEntry.Attributes.FileSize = uint64(sourceSize)
localEntry.Attributes.Mtime = time.Now().Unix()
return localEntry
}
func wholeFileServerCopyCandidate(fhIn, fhOut *FileHandle, in *fuse.CopyFileRangeIn) (copyRequest wholeFileServerCopyRequest, ok bool) {
if fhIn == nil || fhOut == nil || in == nil {
glog.V(4).Infof("server-side copy: skipped (nil handle or input)")
return wholeFileServerCopyRequest{}, false
}
if fhIn.fh == fhOut.fh {
glog.V(4).Infof("server-side copy: skipped (same file handle)")
return wholeFileServerCopyRequest{}, false
}
if fhIn.dirtyMetadata || fhOut.dirtyMetadata {
glog.V(4).Infof("server-side copy: skipped (dirty metadata: in=%v out=%v)", fhIn.dirtyMetadata, fhOut.dirtyMetadata)
return wholeFileServerCopyRequest{}, false
}
if in.OffIn != 0 || in.OffOut != 0 {
glog.V(4).Infof("server-side copy: skipped (non-zero offsets: in=%d out=%d)", in.OffIn, in.OffOut)
return wholeFileServerCopyRequest{}, false
}
srcEntry := fhIn.GetEntry()
dstEntry := fhOut.GetEntry()
if srcEntry == nil || dstEntry == nil {
glog.V(4).Infof("server-side copy: skipped (nil entry: src=%v dst=%v)", srcEntry == nil, dstEntry == nil)
return wholeFileServerCopyRequest{}, false
}
if srcEntry.IsDirectory || dstEntry.IsDirectory {
glog.V(4).Infof("server-side copy: skipped (directory)")
return wholeFileServerCopyRequest{}, false
}
srcPbEntry := srcEntry.GetEntry()
dstPbEntry := dstEntry.GetEntry()
if srcPbEntry == nil || dstPbEntry == nil || srcPbEntry.Attributes == nil || dstPbEntry.Attributes == nil {
glog.V(4).Infof("server-side copy: skipped (missing entry attributes)")
return wholeFileServerCopyRequest{}, false
}
sourceSize := int64(filer.FileSize(srcPbEntry))
// go-fuse exposes CopyFileRange's return value as uint32, so the fast path
// should only claim copies that can be reported without truncation.
if sourceSize <= 0 || sourceSize > math.MaxUint32 || int64(in.Len) < sourceSize {
glog.V(4).Infof("server-side copy: skipped (size mismatch: sourceSize=%d len=%d)", sourceSize, in.Len)
return wholeFileServerCopyRequest{}, false
}
dstSize := int64(filer.FileSize(dstPbEntry))
if dstSize != 0 || len(dstPbEntry.GetChunks()) > 0 || len(dstPbEntry.Content) > 0 {
glog.V(4).Infof("server-side copy: skipped (destination not empty)")
return wholeFileServerCopyRequest{}, false
}
srcPath := fhIn.FullPath()
dstPath := fhOut.FullPath()
if srcPath == "" || dstPath == "" || srcPath == dstPath {
glog.V(4).Infof("server-side copy: skipped (invalid paths: src=%q dst=%q)", srcPath, dstPath)
return wholeFileServerCopyRequest{}, false
}
if srcPbEntry.Attributes.Inode == 0 || dstPbEntry.Attributes.Inode == 0 {
glog.V(4).Infof("server-side copy: skipped (missing inode preconditions: src=%d dst=%d)", srcPbEntry.Attributes.Inode, dstPbEntry.Attributes.Inode)
return wholeFileServerCopyRequest{}, false
}
return wholeFileServerCopyRequest{
srcPath: srcPath,
dstPath: dstPath,
sourceSize: sourceSize,
srcInode: srcPbEntry.Attributes.Inode,
srcMtime: srcPbEntry.Attributes.Mtime,
dstInode: dstPbEntry.Attributes.Inode,
dstMtime: dstPbEntry.Attributes.Mtime,
dstSize: dstSize,
sourceMime: srcPbEntry.Attributes.Mime,
sourceMd5: append([]byte(nil), srcPbEntry.Attributes.Md5...),
copyRequestID: request_id.New(),
}, true
}
func (wfs *WFS) copyEntryViaFiler(cancel <-chan struct{}, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, serverSideWholeFileCopyOutcome, error) {
baseCtx, baseCancel := context.WithCancel(context.Background())
defer baseCancel()
if cancel != nil {
go func() {
select {
case <-cancel:
baseCancel()
case <-baseCtx.Done():
}
}()
}
postCtx, postCancel := context.WithTimeout(baseCtx, filerCopyRequestTimeout)
defer postCancel()
httpClient := util_http.GetGlobalHttpClient()
if httpClient == nil {
var err error
httpClient, err = util_http.NewGlobalHttpClient()
if err != nil {
return nil, serverSideWholeFileCopyNotCommitted, fmt.Errorf("create filer copy http client: %w", err)
}
}
copyURL := &url.URL{
Scheme: httpClient.GetHttpScheme(),
Host: wfs.getCurrentFiler().ToHttpAddress(),
Path: string(copyRequest.dstPath),
}
query := copyURL.Query()
query.Set(filer.CopyQueryParamFrom, string(copyRequest.srcPath))
query.Set(filer.CopyQueryParamOverwrite, "true")
query.Set(filer.CopyQueryParamDataOnly, "true")
query.Set(filer.CopyQueryParamRequestID, copyRequest.copyRequestID)
query.Set(filer.CopyQueryParamSourceInode, fmt.Sprintf("%d", copyRequest.srcInode))
query.Set(filer.CopyQueryParamSourceMtime, fmt.Sprintf("%d", copyRequest.srcMtime))
query.Set(filer.CopyQueryParamSourceSize, fmt.Sprintf("%d", copyRequest.sourceSize))
query.Set(filer.CopyQueryParamDestinationInode, fmt.Sprintf("%d", copyRequest.dstInode))
query.Set(filer.CopyQueryParamDestinationMtime, fmt.Sprintf("%d", copyRequest.dstMtime))
query.Set(filer.CopyQueryParamDestinationSize, fmt.Sprintf("%d", copyRequest.dstSize))
copyURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(postCtx, http.MethodPost, copyURL.String(), nil)
if err != nil {
return nil, serverSideWholeFileCopyNotCommitted, fmt.Errorf("create filer copy request: %w", err)
}
if jwt := wfs.filerCopyJWT(); jwt != "" {
req.Header.Set("Authorization", "Bearer "+string(jwt))
}
resp, err := httpClient.Do(req)
if err != nil {
return wfs.confirmServerSideWholeFileCopyAfterAmbiguousRequest(baseCtx, copyRequest, fmt.Errorf("execute filer copy request: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, serverSideWholeFileCopyNotCommitted, fmt.Errorf("filer copy %s => %s failed: status %d: %s", copyRequest.srcPath, copyRequest.dstPath, resp.StatusCode, string(body))
}
readbackCtx, readbackCancel := context.WithTimeout(baseCtx, filerCopyReadbackTimeout)
defer readbackCancel()
entry, err := filer_pb.GetEntry(readbackCtx, wfs, copyRequest.dstPath)
if err != nil {
return nil, serverSideWholeFileCopyCommitted, fmt.Errorf("reload copied entry %s: %w", copyRequest.dstPath, err)
}
if entry == nil {
return nil, serverSideWholeFileCopyCommitted, fmt.Errorf("reload copied entry %s: not found", copyRequest.dstPath)
}
if entry.Attributes != nil && wfs.option != nil && wfs.option.UidGidMapper != nil {
entry.Attributes.Uid, entry.Attributes.Gid = wfs.option.UidGidMapper.FilerToLocal(entry.Attributes.Uid, entry.Attributes.Gid)
}
return entry, serverSideWholeFileCopyCommitted, nil
}
func (wfs *WFS) confirmServerSideWholeFileCopyAfterAmbiguousRequest(baseCtx context.Context, copyRequest wholeFileServerCopyRequest, requestErr error) (*filer_pb.Entry, serverSideWholeFileCopyOutcome, error) {
readbackCtx, readbackCancel := context.WithTimeout(baseCtx, filerCopyReadbackTimeout)
defer readbackCancel()
entry, err := filer_pb.GetEntry(readbackCtx, wfs, copyRequest.dstPath)
if err == nil && entry != nil && entryMatchesServerSideWholeFileCopy(copyRequest, entry) {
if entry.Attributes != nil && wfs.option != nil && wfs.option.UidGidMapper != nil {
entry.Attributes.Uid, entry.Attributes.Gid = wfs.option.UidGidMapper.FilerToLocal(entry.Attributes.Uid, entry.Attributes.Gid)
}
return entry, serverSideWholeFileCopyCommitted, nil
}
if err != nil {
return nil, serverSideWholeFileCopyAmbiguous, fmt.Errorf("%w; post-copy readback failed: %v", requestErr, err)
}
if entry == nil {
return nil, serverSideWholeFileCopyAmbiguous, fmt.Errorf("%w; destination %s was not readable after the ambiguous request", requestErr, copyRequest.dstPath)
}
return nil, serverSideWholeFileCopyAmbiguous, fmt.Errorf("%w; destination %s did not match the requested copy after the ambiguous request", requestErr, copyRequest.dstPath)
}
func entryMatchesServerSideWholeFileCopy(copyRequest wholeFileServerCopyRequest, entry *filer_pb.Entry) bool {
if entry == nil || entry.Attributes == nil {
return false
}
if copyRequest.dstInode != 0 && entry.Attributes.Inode != copyRequest.dstInode {
return false
}
if entry.Attributes.FileSize != uint64(copyRequest.sourceSize) {
return false
}
if copyRequest.sourceMime != "" && entry.Attributes.Mime != copyRequest.sourceMime {
return false
}
if len(copyRequest.sourceMd5) > 0 && !bytes.Equal(entry.Attributes.Md5, copyRequest.sourceMd5) {
return false
}
return true
}
func (wfs *WFS) filerCopyJWT() security.EncodedJwt {
if wfs.option == nil || len(wfs.option.FilerSigningKey) == 0 {
return ""
}
return security.GenJwtForFilerServer(wfs.option.FilerSigningKey, wfs.option.FilerSigningExpiresAfterSec)
}
func (wfs *WFS) invalidateCopyDestinationCache(inode uint64, fullPath util.FullPath) {
if wfs.fuseServer != nil {
if status := wfs.fuseServer.InodeNotify(inode, 0, -1); status != fuse.OK {
glog.V(4).Infof("CopyFileRange invalidate inode %d: %v", inode, status)
}
dir, name := fullPath.DirAndName()
if parentInode, found := wfs.inodeToPath.GetInode(util.FullPath(dir)); found {
if status := wfs.fuseServer.EntryNotify(parentInode, name); status != fuse.OK {
glog.V(4).Infof("CopyFileRange invalidate entry %s: %v", fullPath, status)
}
}
}
}
+355
View File
@@ -0,0 +1,355 @@
package mount
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestWholeFileServerCopyCandidate(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Inode: srcInode,
},
Content: []byte("hello"),
})
dstHandle := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
Inode: dstInode,
},
})
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
in := &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
}
copyRequest, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in)
if !ok {
t.Fatal("expected whole-file server copy candidate")
}
if copyRequest.srcPath != srcPath {
t.Fatalf("source path = %q, want %q", copyRequest.srcPath, srcPath)
}
if copyRequest.dstPath != dstPath {
t.Fatalf("destination path = %q, want %q", copyRequest.dstPath, dstPath)
}
if copyRequest.sourceSize != 5 {
t.Fatalf("source size = %d, want 5", copyRequest.sourceSize)
}
if copyRequest.srcInode == 0 || copyRequest.dstInode == 0 {
t.Fatalf("expected inode preconditions, got src=%d dst=%d", copyRequest.srcInode, copyRequest.dstInode)
}
srcHandle.dirtyMetadata = true
if _, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in); ok {
t.Fatal("dirty source handle should disable whole-file server copy")
}
srcHandle.dirtyMetadata = false
in.Len = 4
if _, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in); ok {
t.Fatal("short copy request should disable whole-file server copy")
}
}
func TestCopyFileRangeUsesServerSideWholeFileCopy(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Inode: srcInode,
},
Content: []byte("hello"),
})
dstHandle := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
Inode: dstInode,
},
})
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
originalCopy := performServerSideWholeFileCopy
defer func() {
performServerSideWholeFileCopy = originalCopy
}()
var called bool
performServerSideWholeFileCopy = func(cancel <-chan struct{}, gotWFS *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, serverSideWholeFileCopyOutcome, error) {
called = true
if gotWFS != wfs {
t.Fatalf("wfs = %p, want %p", gotWFS, wfs)
}
if copyRequest.srcPath != srcPath {
t.Fatalf("source path = %q, want %q", copyRequest.srcPath, srcPath)
}
if copyRequest.dstPath != dstPath {
t.Fatalf("destination path = %q, want %q", copyRequest.dstPath, dstPath)
}
return &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Mime: "text/plain; charset=utf-8",
},
Content: []byte("hello"),
}, serverSideWholeFileCopyCommitted, nil
}
written, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
})
if status != fuse.OK {
t.Fatalf("CopyFileRange status = %v, want OK", status)
}
if written != 5 {
t.Fatalf("CopyFileRange wrote %d bytes, want 5", written)
}
if !called {
t.Fatal("expected server-side whole-file copy path to be used")
}
gotEntry := dstHandle.GetEntry().GetEntry()
if gotEntry.Attributes == nil || gotEntry.Attributes.FileSize != 5 {
t.Fatalf("destination size = %v, want 5", gotEntry.GetAttributes().GetFileSize())
}
if string(gotEntry.Content) != "hello" {
t.Fatalf("destination content = %q, want %q", string(gotEntry.Content), "hello")
}
if dstHandle.dirtyMetadata {
t.Fatal("server-side whole-file copy should leave destination handle clean")
}
}
func TestCopyFileRangeDoesNotFallbackAfterCommittedServerCopyRefreshFailure(t *testing.T) {
wfs := newCopyRangeTestWFSWithMetaCache(t)
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Mime: "text/plain; charset=utf-8",
Md5: []byte("abcde"),
Inode: srcInode,
},
Content: []byte("hello"),
})
dstHandle := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100600,
Inode: dstInode,
},
})
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
originalCopy := performServerSideWholeFileCopy
defer func() {
performServerSideWholeFileCopy = originalCopy
}()
performServerSideWholeFileCopy = func(cancel <-chan struct{}, gotWFS *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, serverSideWholeFileCopyOutcome, error) {
if gotWFS != wfs || copyRequest.srcPath != srcPath || copyRequest.dstPath != dstPath {
t.Fatalf("unexpected server-side copy call: wfs=%p src=%q dst=%q", gotWFS, copyRequest.srcPath, copyRequest.dstPath)
}
return nil, serverSideWholeFileCopyCommitted, errors.New("reload copied entry: transient filer read failure")
}
written, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
})
if status != fuse.OK {
t.Fatalf("CopyFileRange status = %v, want OK", status)
}
if written != 5 {
t.Fatalf("CopyFileRange wrote %d bytes, want 5", written)
}
if dstHandle.dirtyMetadata {
t.Fatal("committed server-side copy should not fall back to dirty-page copy")
}
gotEntry := dstHandle.GetEntry().GetEntry()
if gotEntry.GetAttributes().GetFileSize() != 5 {
t.Fatalf("destination size = %d, want 5", gotEntry.GetAttributes().GetFileSize())
}
if gotEntry.GetAttributes().GetFileMode() != 0100600 {
t.Fatalf("destination mode = %#o, want %#o", gotEntry.GetAttributes().GetFileMode(), uint32(0100600))
}
if string(gotEntry.GetContent()) != "hello" {
t.Fatalf("destination content = %q, want %q", string(gotEntry.GetContent()), "hello")
}
cachedEntry, err := wfs.metaCache.FindEntry(context.Background(), dstPath)
if err != nil {
t.Fatalf("metaCache find entry: %v", err)
}
if cachedEntry.FileSize != 5 {
t.Fatalf("metaCache destination size = %d, want 5", cachedEntry.FileSize)
}
if cachedEntry.Mime != "text/plain; charset=utf-8" {
t.Fatalf("metaCache destination mime = %q, want %q", cachedEntry.Mime, "text/plain; charset=utf-8")
}
}
func TestCopyFileRangeReturnsEIOForAmbiguousServerSideCopy(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Inode: srcInode,
},
Content: []byte("hello"),
})
dstHandle := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100600,
Inode: dstInode,
},
})
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
originalCopy := performServerSideWholeFileCopy
defer func() {
performServerSideWholeFileCopy = originalCopy
}()
performServerSideWholeFileCopy = func(cancel <-chan struct{}, gotWFS *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, serverSideWholeFileCopyOutcome, error) {
if gotWFS != wfs || copyRequest.srcPath != srcPath || copyRequest.dstPath != dstPath {
t.Fatalf("unexpected server-side copy call: wfs=%p src=%q dst=%q", gotWFS, copyRequest.srcPath, copyRequest.dstPath)
}
return nil, serverSideWholeFileCopyAmbiguous, errors.New("transport timeout after request dispatch")
}
written, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
})
if status != fuse.EIO {
t.Fatalf("CopyFileRange status = %v, want EIO", status)
}
if written != 0 {
t.Fatalf("CopyFileRange wrote %d bytes, want 0", written)
}
if dstHandle.dirtyMetadata {
t.Fatal("ambiguous server-side copy should not fall back to dirty-page copy")
}
if dstHandle.GetEntry().GetEntry().GetAttributes().GetFileSize() != 0 {
t.Fatalf("destination size = %d, want 0", dstHandle.GetEntry().GetEntry().GetAttributes().GetFileSize())
}
}
func newCopyRangeTestWFS() *WFS {
wfs := &WFS{
option: &Option{
ChunkSizeLimit: 1024,
ConcurrentReaders: 1,
VolumeServerAccess: "filerProxy",
FilerAddresses: []pb.ServerAddress{"127.0.0.1:8888"},
},
inodeToPath: NewInodeToPath(util.FullPath("/"), 0),
fhMap: NewFileHandleToInode(),
fhLockTable: util.NewLockTable[FileHandleId](),
}
wfs.copyBufferPool.New = func() any {
return make([]byte, 1024)
}
return wfs
}
func newCopyRangeTestWFSWithMetaCache(t *testing.T) *WFS {
t.Helper()
wfs := newCopyRangeTestWFS()
root := util.FullPath("/")
wfs.inodeToPath.MarkChildrenCached(root)
uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
if err != nil {
t.Fatalf("create uid/gid mapper: %v", err)
}
wfs.metaCache = meta_cache.NewMetaCache(
filepath.Join(t.TempDir(), "meta"),
uidGidMapper,
root,
func(path util.FullPath) {
wfs.inodeToPath.MarkChildrenCached(path)
},
func(path util.FullPath) bool {
return wfs.inodeToPath.IsChildrenCached(path)
},
func(util.FullPath, *filer_pb.Entry) {},
nil,
)
t.Cleanup(func() {
wfs.metaCache.Shutdown()
})
return wfs
}
+5 -13
View File
@@ -1,9 +1,6 @@
package mount
import (
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
import "github.com/seaweedfs/go-fuse/v2/fuse"
/**
* Open a file
@@ -66,15 +63,7 @@ func (wfs *WFS) Open(cancel <-chan struct{}, in *fuse.OpenIn, out *fuse.OpenOut)
fileHandle, status = wfs.AcquireHandle(in.NodeId, in.Flags, in.Uid, in.Gid)
if status == fuse.OK {
out.Fh = uint64(fileHandle.fh)
out.OpenFlags = in.Flags
if wfs.option.IsMacOs {
// remove the direct_io flag, as it is not well-supported on macOS
// https://code.google.com/archive/p/macfuse/wikis/OPTIONS.wiki recommended to avoid the direct_io flag
if in.Flags&fuse.FOPEN_DIRECT_IO != 0 {
glog.V(4).Infof("macfuse direct_io mode %v => false\n", in.Flags&fuse.FOPEN_DIRECT_IO != 0)
out.OpenFlags &^= fuse.FOPEN_DIRECT_IO
}
}
out.OpenFlags = 0
// TODO https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h#L64
}
return status
@@ -106,5 +95,8 @@ func (wfs *WFS) Open(cancel <-chan struct{}, in *fuse.OpenIn, out *fuse.OpenOut)
* @param fi file information
*/
func (wfs *WFS) Release(cancel <-chan struct{}, in *fuse.ReleaseIn) {
if in.ReleaseFlags&fuse.FUSE_RELEASE_FLOCK_UNLOCK != 0 {
wfs.posixLocks.ReleaseFlockOwner(in.NodeId, in.LockOwner)
}
wfs.ReleaseHandle(FileHandleId(in.Fh))
}
+54
View File
@@ -0,0 +1,54 @@
package mount
import (
"syscall"
"github.com/seaweedfs/go-fuse/v2/fuse"
)
// GetLk queries for a conflicting lock on the file.
// If a conflict exists, the conflicting lock is returned in out.
// If no conflict, out.Lk.Typ is set to F_UNLCK.
func (wfs *WFS) GetLk(cancel <-chan struct{}, in *fuse.LkIn, out *fuse.LkOut) fuse.Status {
proposed := lockRange{
Start: in.Lk.Start,
End: in.Lk.End,
Typ: in.Lk.Typ,
Owner: in.Owner,
Pid: in.Lk.Pid,
IsFlock: in.LkFlags&fuse.FUSE_LK_FLOCK != 0,
}
wfs.posixLocks.GetLk(in.NodeId, proposed, out)
return fuse.OK
}
// SetLk sets or clears a POSIX lock (non-blocking).
// Returns EAGAIN if the lock conflicts with an existing lock from another owner.
func (wfs *WFS) SetLk(cancel <-chan struct{}, in *fuse.LkIn) fuse.Status {
lk := lockRange{
Start: in.Lk.Start,
End: in.Lk.End,
Typ: in.Lk.Typ,
Owner: in.Owner,
Pid: in.Lk.Pid,
IsFlock: in.LkFlags&fuse.FUSE_LK_FLOCK != 0,
}
return wfs.posixLocks.SetLk(in.NodeId, lk)
}
// SetLkw sets a POSIX lock (blocking).
// Waits until the lock can be acquired or the request is cancelled.
func (wfs *WFS) SetLkw(cancel <-chan struct{}, in *fuse.LkIn) fuse.Status {
lk := lockRange{
Start: in.Lk.Start,
End: in.Lk.End,
Typ: in.Lk.Typ,
Owner: in.Owner,
Pid: in.Lk.Pid,
IsFlock: in.LkFlags&fuse.FUSE_LK_FLOCK != 0,
}
if lk.Typ == syscall.F_UNLCK {
return wfs.posixLocks.SetLk(in.NodeId, lk)
}
return wfs.posixLocks.SetLkw(in.NodeId, lk, cancel)
}
+251 -74
View File
@@ -6,8 +6,10 @@ import (
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
/**
@@ -21,10 +23,97 @@ import (
* will be called instead.
*/
func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, out *fuse.CreateOut) (code fuse.Status) {
// if implemented, need to use
// inode := wfs.inodeToPath.Lookup(entryFullPath)
// to ensure nlookup counter
return fuse.ENOSYS
if s := checkName(name); s != fuse.OK {
return s
}
dirFullPath, code := wfs.inodeToPath.GetPath(in.NodeId)
if code != fuse.OK {
return code
}
entryFullPath := dirFullPath.Child(name)
var inode uint64
newEntry, code := wfs.maybeLoadEntry(entryFullPath)
if code == fuse.OK {
if newEntry == nil || newEntry.Attributes == nil {
return fuse.EIO
}
if in.Flags&syscall.O_EXCL != 0 {
glog.V(0).Infof("Create O_EXCL %s: already exists (uid=%d gid=%d mode=%o)",
entryFullPath, newEntry.Attributes.Uid, newEntry.Attributes.Gid, newEntry.Attributes.FileMode)
return fuse.Status(syscall.EEXIST)
}
inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, len(newEntry.HardLinkId) > 0, newEntry.Attributes.Inode, true)
fileHandle, status := wfs.AcquireHandle(inode, in.Flags, in.Uid, in.Gid)
if status != fuse.OK {
return status
}
if in.Flags&syscall.O_TRUNC != 0 && in.Flags&fuse.O_ANYWRITE != 0 {
if code = wfs.truncateEntry(entryFullPath, newEntry); code != fuse.OK {
wfs.ReleaseHandle(fileHandle.fh)
return code
}
newEntry = fileHandle.GetEntry().GetEntry()
}
wfs.outputPbEntry(&out.EntryOut, inode, newEntry)
out.Fh = uint64(fileHandle.fh)
out.OpenFlags = 0
return fuse.OK
}
if code != fuse.ENOENT {
return code
}
inode, newEntry, code = wfs.createRegularFile(dirFullPath, name, in.Mode, in.Uid, in.Gid, 0, 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.
newEntry, code = wfs.maybeLoadEntry(entryFullPath)
if code != fuse.OK {
return code
}
if newEntry == nil || newEntry.Attributes == nil {
return fuse.EIO
}
inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, len(newEntry.HardLinkId) > 0, newEntry.Attributes.Inode, true)
fileHandle, status := wfs.AcquireHandle(inode, in.Flags, in.Uid, in.Gid)
if status != fuse.OK {
return status
}
if in.Flags&syscall.O_TRUNC != 0 && in.Flags&fuse.O_ANYWRITE != 0 {
if code = wfs.truncateEntry(entryFullPath, newEntry); code != fuse.OK {
wfs.ReleaseHandle(fileHandle.fh)
return code
}
newEntry = fileHandle.GetEntry().GetEntry()
}
wfs.outputPbEntry(&out.EntryOut, inode, newEntry)
out.Fh = uint64(fileHandle.fh)
out.OpenFlags = 0
return fuse.OK
} else if code != fuse.OK {
return code
} else {
inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, false, inode, true)
}
wfs.outputPbEntry(&out.EntryOut, inode, newEntry)
// For deferred creates, bypass AcquireHandle (which calls maybeReadEntry
// and would fail since the entry is not yet on the filer or in the meta cache).
// We already have the entry from createRegularFile, so create the handle directly.
fileHandle := wfs.fhMap.AcquireFileHandle(wfs, inode, newEntry)
fileHandle.RememberPath(entryFullPath)
// Mark dirty so the deferred filer create happens on Flush,
// even if the file is closed without any writes.
fileHandle.dirtyMetadata = true
out.Fh = uint64(fileHandle.fh)
out.OpenFlags = 0
return fuse.OK
}
/** Create a file node
@@ -35,10 +124,6 @@ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, o
*/
func (wfs *WFS) Mknod(cancel <-chan struct{}, in *fuse.MknodIn, name string, out *fuse.EntryOut) (code fuse.Status) {
if wfs.IsOverQuotaWithUncommitted() {
return fuse.Status(syscall.ENOSPC)
}
if s := checkName(name); s != fuse.OK {
return s
}
@@ -48,65 +133,13 @@ func (wfs *WFS) Mknod(cancel <-chan struct{}, in *fuse.MknodIn, name string, out
return
}
entryFullPath := dirFullPath.Child(name)
fileMode := toOsFileMode(in.Mode)
now := time.Now().Unix()
inode := wfs.inodeToPath.AllocateInode(entryFullPath, now)
newEntry := &filer_pb.Entry{
Name: name,
IsDirectory: false,
Attributes: &filer_pb.FuseAttributes{
Mtime: now,
Crtime: now,
FileMode: uint32(fileMode),
Uid: in.Uid,
Gid: in.Gid,
TtlSec: wfs.option.TtlSec,
Rdev: in.Rdev,
Inode: inode,
},
}
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
wfs.mapPbIdFromLocalToFiler(newEntry)
defer wfs.mapPbIdFromFilerToLocal(newEntry)
request := &filer_pb.CreateEntryRequest{
Directory: string(dirFullPath),
Entry: newEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
glog.V(1).Infof("mknod: %v", request)
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
glog.V(0).Infof("mknod %s: %v", entryFullPath, err)
return err
}
event := resp.GetMetadataEvent()
if event == nil {
event = metadataCreateEvent(string(dirFullPath), newEntry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("mknod %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
}
wfs.inodeToPath.TouchDirectory(dirFullPath)
return nil
})
glog.V(3).Infof("mknod %s: %v", entryFullPath, err)
if err != nil {
return fuse.EIO
inode, newEntry, code := wfs.createRegularFile(dirFullPath, name, in.Mode, in.Uid, in.Gid, in.Rdev, false)
if code != fuse.OK {
return code
}
// this is to increase nlookup counter
entryFullPath := dirFullPath.Child(name)
inode = wfs.inodeToPath.Lookup(entryFullPath, newEntry.Attributes.Crtime, false, false, inode, true)
wfs.outputPbEntry(out, inode, newEntry)
@@ -139,11 +172,37 @@ func (wfs *WFS) Unlink(cancel <-chan struct{}, header *fuse.InHeader, name strin
return fuse.EPERM
}
// Before deleting from the filer, mark any draining async-flush handle
// as deleted and wait for it to complete. Without this, the async flush
// can race with the filer delete and recreate the just-unlinked entry
// (the worker checks isDeleted, but it may have already passed that check
// before Unlink sets the flag). By waiting here, any in-flight flush
// finishes first; even if it recreated the entry, the filer delete below
// will remove it again.
if inode, found := wfs.inodeToPath.GetInode(entryFullPath); found {
if fh, fhFound := wfs.fhMap.FindFileHandle(inode); fhFound {
fh.isDeleted = true
}
wfs.waitForPendingAsyncFlush(inode)
} else if entry != nil && entry.Attributes != nil && entry.Attributes.Inode != 0 {
inodeFromEntry := entry.Attributes.Inode
if fh, fhFound := wfs.fhMap.FindFileHandle(inodeFromEntry); fhFound {
fh.isDeleted = true
}
wfs.waitForPendingAsyncFlush(inodeFromEntry)
}
// first, ensure the filer store can correctly delete
glog.V(3).Infof("remove file: %v", entryFullPath)
// Always let the filer decide whether to delete chunks based on its authoritative data.
// The filer has the correct hard link count and will only delete chunks when appropriate.
resp, err := filer_pb.RemoveWithResponse(context.Background(), wfs, string(dirFullPath), name, true, false, false, false, []int32{wfs.signature})
deleteReq := &filer_pb.DeleteEntryRequest{
Directory: string(dirFullPath),
Name: name,
IsDeleteData: true,
Signatures: []int32{wfs.signature},
}
resp, err := wfs.streamDeleteEntry(context.Background(), deleteReq)
if err != nil {
glog.V(0).Infof("remove %s: %v", entryFullPath, err)
return fuse.OK
@@ -161,17 +220,135 @@ func (wfs *WFS) Unlink(cancel <-chan struct{}, header *fuse.InHeader, name strin
}
wfs.inodeToPath.TouchDirectory(dirFullPath)
// If there is an async-draining handle for this file, mark it as deleted
// so the background flush skips the metadata write instead of recreating
// the just-unlinked entry. The handle is still in fhMap during drain.
if inode, found := wfs.inodeToPath.GetInode(entryFullPath); found {
if fh, fhFound := wfs.fhMap.FindFileHandle(inode); fhFound {
fh.isDeleted = true
}
}
wfs.inodeToPath.RemovePath(entryFullPath)
return fuse.OK
}
func (wfs *WFS) createRegularFile(dirFullPath util.FullPath, name string, mode uint32, uid, gid, rdev uint32, deferFilerCreate bool) (inode uint64, newEntry *filer_pb.Entry, code fuse.Status) {
if wfs.IsOverQuotaWithUncommitted() {
return 0, nil, fuse.Status(syscall.ENOSPC)
}
// Verify write+search permission on the parent directory.
parentEntry, parentStatus := wfs.maybeLoadEntry(dirFullPath)
if parentStatus != fuse.OK {
return 0, nil, parentStatus
}
if parentEntry == nil || parentEntry.Attributes == nil {
return 0, nil, fuse.EIO
}
// Map parent dir uid/gid from filer-space to local-space so the
// permission check compares like with like (caller uid/gid are local).
parentUid, parentGid := parentEntry.Attributes.Uid, parentEntry.Attributes.Gid
if wfs.option.UidGidMapper != nil {
parentUid, parentGid = wfs.option.UidGidMapper.FilerToLocal(parentUid, parentGid)
}
if !hasAccess(uid, gid, parentUid, parentGid, parentEntry.Attributes.FileMode, fuse.W_OK|fuse.X_OK) {
return 0, nil, fuse.Status(syscall.EACCES)
}
entryFullPath := dirFullPath.Child(name)
if _, status := wfs.maybeLoadEntry(entryFullPath); status == fuse.OK {
return 0, nil, fuse.Status(syscall.EEXIST)
} else if status != fuse.ENOENT {
return 0, nil, status
}
fileMode := toOsFileMode(mode)
now := time.Now().Unix()
inode = wfs.inodeToPath.AllocateInode(entryFullPath, now)
newEntry = &filer_pb.Entry{
Name: name,
IsDirectory: false,
Attributes: &filer_pb.FuseAttributes{
Mtime: now,
Crtime: now,
FileMode: uint32(fileMode),
Uid: uid,
Gid: gid,
TtlSec: wfs.option.TtlSec,
Rdev: rdev,
Inode: inode,
},
}
if deferFilerCreate {
// Defer the filer gRPC call to flush time. The caller (Create) will
// build a file handle directly from newEntry, bypassing AcquireHandle.
// Insert a local placeholder into the metadata cache so that
// maybeLoadEntry() can find the file (e.g., duplicate-create checks,
// stat, readdir). The actual filer entry is created by flushMetadataToFiler.
// We use InsertEntry directly instead of applyLocalMetadataEvent to avoid
// triggering directory hot-threshold eviction that would wipe the entry.
if insertErr := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(string(dirFullPath), newEntry)); insertErr != nil {
glog.Warningf("createFile %s: insert local entry: %v", entryFullPath, insertErr)
}
glog.V(3).Infof("createFile %s: deferred to flush", entryFullPath)
return inode, newEntry, fuse.OK
}
wfs.mapPbIdFromLocalToFiler(newEntry)
defer wfs.mapPbIdFromFilerToLocal(newEntry)
request := &filer_pb.CreateEntryRequest{
Directory: string(dirFullPath),
Entry: newEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
glog.V(1).Infof("createFile: %v", request)
resp, err := wfs.streamCreateEntry(context.Background(), request)
if err != nil {
glog.V(0).Infof("createFile %s: %v", entryFullPath, err)
} else {
event := resp.GetMetadataEvent()
if event == nil {
event = metadataCreateEvent(string(dirFullPath), newEntry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("createFile %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
}
wfs.inodeToPath.TouchDirectory(dirFullPath)
}
glog.V(3).Infof("createFile %s: %v", entryFullPath, err)
if err != nil {
return 0, nil, grpcErrorToFuseStatus(err)
}
return inode, newEntry, fuse.OK
}
func (wfs *WFS) truncateEntry(entryFullPath util.FullPath, entry *filer_pb.Entry) fuse.Status {
if entry == nil {
return fuse.EIO
}
if entry.Attributes == nil {
entry.Attributes = &filer_pb.FuseAttributes{}
}
entry.Content = nil
entry.Chunks = nil
entry.Attributes.FileSize = 0
entry.Attributes.Mtime = time.Now().Unix()
if code := wfs.saveEntry(entryFullPath, entry); code != fuse.OK {
return code
}
if inode, found := wfs.inodeToPath.GetInode(entryFullPath); found {
if fh, fhFound := wfs.fhMap.FindFileHandle(inode); fhFound {
fhActiveLock := fh.wfs.fhLockTable.AcquireLock("truncateEntry", fh.fh, util.ExclusiveLock)
fh.ResetDirtyPages()
fh.SetEntry(entry)
fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
}
}
return fuse.OK
}
+382
View File
@@ -0,0 +1,382 @@
package mount
import (
"context"
"net"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type createEntryTestServer struct {
filer_pb.UnimplementedSeaweedFilerServer
mu sync.Mutex
lastDirectory string
lastName string
lastUID uint32
lastGID uint32
lastMode uint32
}
type createEntrySnapshot struct {
directory string
name string
uid uint32
gid uint32
mode uint32
}
func (s *createEntryTestServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastDirectory = req.GetDirectory()
if req.GetEntry() != nil {
s.lastName = req.GetEntry().GetName()
if req.GetEntry().GetAttributes() != nil {
s.lastUID = req.GetEntry().GetAttributes().GetUid()
s.lastGID = req.GetEntry().GetAttributes().GetGid()
s.lastMode = req.GetEntry().GetAttributes().GetFileMode()
}
}
return &filer_pb.CreateEntryResponse{}, nil
}
func (s *createEntryTestServer) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
return &filer_pb.UpdateEntryResponse{}, nil
}
func (s *createEntryTestServer) snapshot() createEntrySnapshot {
s.mu.Lock()
defer s.mu.Unlock()
return createEntrySnapshot{
directory: s.lastDirectory,
name: s.lastName,
uid: s.lastUID,
gid: s.lastGID,
mode: s.lastMode,
}
}
func newCreateTestWFS(t *testing.T) (*WFS, *createEntryTestServer) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
server := pb.NewGrpcServer()
testServer := &createEntryTestServer{}
filer_pb.RegisterSeaweedFilerServer(server, testServer)
go server.Serve(listener)
t.Cleanup(server.Stop)
uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
if err != nil {
t.Fatalf("create uid/gid mapper: %v", err)
}
root := util.FullPath("/")
option := &Option{
ChunkSizeLimit: 1024,
ConcurrentReaders: 1,
VolumeServerAccess: "filerProxy",
FilerAddresses: []pb.ServerAddress{
pb.NewServerAddressWithGrpcPort("127.0.0.1:1", listener.Addr().(*net.TCPAddr).Port),
},
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
FilerMountRootPath: "/",
MountUid: 99,
MountGid: 100,
MountMode: 0o777,
MountMtime: time.Now(),
MountCtime: time.Now(),
UidGidMapper: uidGidMapper,
uniqueCacheDirForWrite: t.TempDir(),
}
wfs := &WFS{
option: option,
signature: 1,
inodeToPath: NewInodeToPath(root, 0),
fhMap: NewFileHandleToInode(),
fhLockTable: util.NewLockTable[FileHandleId](),
}
wfs.metaCache = meta_cache.NewMetaCache(
filepath.Join(t.TempDir(), "meta"),
uidGidMapper,
root,
func(path util.FullPath) {
wfs.inodeToPath.MarkChildrenCached(path)
},
func(path util.FullPath) bool {
return wfs.inodeToPath.IsChildrenCached(path)
},
func(util.FullPath, *filer_pb.Entry) {},
nil,
)
wfs.inodeToPath.MarkChildrenCached(root)
t.Cleanup(func() {
wfs.metaCache.Shutdown()
})
return wfs, testServer
}
func TestCreateCreatesAndOpensFile(t *testing.T) {
wfs, testServer := newCreateTestWFS(t)
out := &fuse.CreateOut{}
status := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{
NodeId: 1,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 456,
},
},
},
Flags: syscall.O_WRONLY | syscall.O_CREAT,
Mode: 0o640,
}, "hello.txt", out)
if status != fuse.OK {
t.Fatalf("Create status = %v, want OK", status)
}
if out.NodeId == 0 {
t.Fatal("Create returned zero inode")
}
if out.Fh == 0 {
t.Fatal("Create returned zero file handle")
}
if out.OpenFlags != 0 {
t.Fatalf("Create returned OpenFlags = %#x, want 0", out.OpenFlags)
}
fileHandle := wfs.GetHandle(FileHandleId(out.Fh))
if fileHandle == nil {
t.Fatal("Create did not register an open file handle")
}
if got := fileHandle.FullPath(); got != "/hello.txt" {
t.Fatalf("FullPath = %q, want %q", got, "/hello.txt")
}
// File creation is deferred to flush time. Trigger a synchronous flush
// so the CreateEntry gRPC call is sent to the test server.
if flushStatus := wfs.Flush(make(chan struct{}), &fuse.FlushIn{
InHeader: fuse.InHeader{
NodeId: out.NodeId,
Caller: fuse.Caller{Owner: fuse.Owner{Uid: 123, Gid: 456}},
},
Fh: out.Fh,
}); flushStatus != fuse.OK {
t.Fatalf("Flush status = %v, want OK", flushStatus)
}
snapshot := testServer.snapshot()
if snapshot.directory != "/" {
t.Fatalf("CreateEntry directory = %q, want %q", snapshot.directory, "/")
}
if snapshot.name != "hello.txt" {
t.Fatalf("CreateEntry name = %q, want %q", snapshot.name, "hello.txt")
}
if snapshot.uid != 123 || snapshot.gid != 456 {
t.Fatalf("CreateEntry uid/gid = %d/%d, want 123/456", snapshot.uid, snapshot.gid)
}
if snapshot.mode != 0o640 {
t.Fatalf("CreateEntry mode = %o, want %o", snapshot.mode, 0o640)
}
}
func TestTruncateEntryClearsDirtyPagesForOpenHandle(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
fullPath := util.FullPath("/truncate.txt")
inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true)
entry := &filer_pb.Entry{
Name: "truncate.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o644,
FileSize: 5,
Inode: inode,
Crtime: 1,
Mtime: 1,
},
}
fh := wfs.fhMap.AcquireFileHandle(wfs, inode, entry)
fh.RememberPath(fullPath)
if err := fh.dirtyPages.AddPage(0, []byte("hello"), true, time.Now().UnixNano()); err != nil {
t.Fatalf("AddPage: %v", err)
}
oldDirtyPages := fh.dirtyPages
truncatedEntry := &filer_pb.Entry{
Name: "truncate.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o644,
FileSize: 5,
Inode: inode,
Crtime: 1,
Mtime: 1,
},
}
if status := wfs.truncateEntry(fullPath, truncatedEntry); status != fuse.OK {
t.Fatalf("truncateEntry status = %v, want OK", status)
}
if fh.dirtyPages == oldDirtyPages {
t.Fatal("truncateEntry should replace the dirtyPages writer for an open handle")
}
if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 0 {
t.Fatalf("file handle size = %d, want 0", got)
}
buf := make([]byte, 5)
if maxStop := fh.dirtyPages.ReadDirtyDataAt(buf, 0, time.Now().UnixNano()); maxStop != 0 {
t.Fatalf("dirty pages maxStop = %d, want 0 after truncate", maxStop)
}
}
func TestAccessChecksPermissions(t *testing.T) {
wfs := newCopyRangeTestWFS()
oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs
lookupSupplementaryGroupIDs = func(uint32) ([]string, error) {
return nil, nil
}
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
})
fullPath := util.FullPath("/visible.txt")
inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true)
handle := wfs.fhMap.AcquireFileHandle(wfs, inode, &filer_pb.Entry{
Name: "visible.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o640,
Uid: 123,
Gid: 456,
Inode: inode,
},
})
handle.RememberPath(fullPath)
if status := wfs.Access(make(chan struct{}), &fuse.AccessIn{
InHeader: fuse.InHeader{
NodeId: inode,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 999,
},
},
},
Mask: fuse.R_OK | fuse.W_OK,
}); status != fuse.OK {
t.Fatalf("owner Access status = %v, want OK", status)
}
if status := wfs.Access(make(chan struct{}), &fuse.AccessIn{
InHeader: fuse.InHeader{
NodeId: inode,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 999,
Gid: 999,
},
},
},
Mask: fuse.W_OK,
}); status != fuse.EACCES {
t.Fatalf("other-user Access status = %v, want EACCES", status)
}
if got := hasAccess(123, 999, 123, 456, 0o400, fuse.R_OK|fuse.W_OK); got {
t.Fatal("owner should not get write access from a read-only owner mode")
}
if got := hasAccess(999, 456, 123, 456, 0o040, fuse.R_OK|fuse.W_OK); got {
t.Fatal("group member should not get write access from a read-only group mode")
}
if got := hasAccess(999, 999, 123, 456, 0o004, fuse.R_OK|fuse.W_OK); got {
t.Fatal("other users should not get write access from a read-only other mode")
}
if got := hasAccess(0, 0, 123, 456, 0o644, fuse.X_OK); got {
t.Fatal("root should not get execute access when no execute bit is set")
}
if got := hasAccess(0, 0, 123, 456, 0o755, fuse.R_OK|fuse.X_OK); !got {
t.Fatal("root should get execute access when at least one execute bit is set")
}
}
func TestHasAccessUsesSupplementaryGroups(t *testing.T) {
oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs
lookupSupplementaryGroupIDs = func(uint32) ([]string, error) {
return []string{"456"}, nil
}
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
})
if got := hasAccess(999, 999, 123, 456, 0o060, fuse.R_OK|fuse.W_OK); !got {
t.Fatal("supplementary group membership should grant matching group permissions")
}
}
func TestCreateExistingFileIgnoresQuotaPreflight(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
wfs.option.Quota = 1
wfs.IsOverQuota = true
entry := &filer_pb.Entry{
Name: "existing.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o644,
FileSize: 7,
Inode: 101,
Crtime: 1,
Mtime: 1,
Uid: 123,
Gid: 456,
},
}
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/", entry)); err != nil {
t.Fatalf("InsertEntry: %v", err)
}
wfs.inodeToPath.Lookup(util.FullPath("/existing.txt"), entry.Attributes.Crtime, false, false, entry.Attributes.Inode, true)
out := &fuse.CreateOut{}
status := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{
NodeId: 1,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 456,
},
},
},
Flags: syscall.O_WRONLY | syscall.O_CREAT | syscall.O_EXCL,
Mode: 0o644,
}, "existing.txt", out)
if status != fuse.Status(syscall.EEXIST) {
t.Fatalf("Create status = %v, want EEXIST", status)
}
}
+65 -50
View File
@@ -11,6 +11,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
/**
@@ -56,10 +57,20 @@ func (wfs *WFS) Flush(cancel <-chan struct{}, in *fuse.FlushIn) fuse.Status {
if fh == nil {
// If handle is not found, it might have been already released
// This is not an error condition for FLUSH
if in.LockOwner != 0 {
wfs.posixLocks.ReleasePosixOwner(in.NodeId, in.LockOwner)
}
return fuse.OK
}
return wfs.doFlush(fh, in.Uid, in.Gid, true)
// When a closing lock owner is present, flush synchronously before waking any
// blocked POSIX lock waiters so write-serialized callers cannot overtake each other.
allowAsync := in.LockOwner == 0
status := wfs.doFlush(fh, in.Uid, in.Gid, allowAsync)
if in.LockOwner != 0 {
wfs.posixLocks.ReleasePosixOwner(in.NodeId, in.LockOwner)
}
return status
}
/**
@@ -97,6 +108,7 @@ func (wfs *WFS) doFlush(fh *FileHandle, uid, gid uint32, allowAsync bool) fuse.S
// flush works at fh level
fileFullPath := fh.FullPath()
fh.RememberPath(fileFullPath)
dir, name := fileFullPath.DirAndName()
// send the data to the OS
glog.V(4).Infof("doFlush %s fh %d", fileFullPath, fh.fh)
@@ -112,8 +124,6 @@ func (wfs *WFS) doFlush(fh *FileHandle, uid, gid uint32, allowAsync bool) fuse.S
fh.asyncFlushPending = true
fh.asyncFlushUid = uid
fh.asyncFlushGid = gid
fh.asyncFlushDir = dir
fh.asyncFlushName = name
glog.V(3).Infof("doFlush async deferred %s fh %d", fileFullPath, fh.fh)
return fuse.OK
}
@@ -138,9 +148,14 @@ func (wfs *WFS) doFlush(fh *FileHandle, uid, gid uint32, allowAsync bool) fuse.S
return fuse.Status(syscall.ENOSPC)
}
if err := wfs.flushMetadataToFiler(fh, dir, name, uid, gid); err != nil {
if err := retryMetadataFlush(func() error {
return wfs.flushMetadataToFiler(fh, dir, name, uid, gid)
}, func(nextAttempt, totalAttempts int, backoff time.Duration, err error) {
glog.Warningf("%v fh %d flush: retrying metadata flush (attempt %d/%d) after %v: %v",
fileFullPath, fh.fh, nextAttempt, totalAttempts, backoff, err)
}); err != nil {
glog.Errorf("%v fh %d flush: %v", fileFullPath, fh.fh, err)
return fuse.EIO
return grpcErrorToFuseStatus(err)
}
if IsDebugFileReadWrite {
@@ -154,65 +169,65 @@ func (wfs *WFS) doFlush(fh *FileHandle, uid, gid uint32, allowAsync bool) fuse.S
// This is shared between the synchronous doFlush path and the async flush completion.
func (wfs *WFS) flushMetadataToFiler(fh *FileHandle, dir, name string, uid, gid uint32) error {
fileFullPath := fh.FullPath()
glog.V(4).Infof("flushMetadataToFiler %s/%s inode %d fh %d", dir, name, fh.inode, fh.fh)
fhActiveLock := fh.wfs.fhLockTable.AcquireLock("doFlush", fh.fh, util.ExclusiveLock)
defer fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock)
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
entry := fh.GetEntry()
entry.Name = name // this flush may be just after a rename operation
entry := fh.GetEntry()
entry.Name = name // this flush may be just after a rename operation
if entry.Attributes != nil {
entry.Attributes.Mime = fh.contentType
if entry.Attributes.Uid == 0 {
entry.Attributes.Uid = uid
}
if entry.Attributes.Gid == 0 {
entry.Attributes.Gid = gid
}
entry.Attributes.Mtime = time.Now().Unix()
if entry.Attributes != nil {
entry.Attributes.Mime = fh.contentType
if entry.Attributes.Uid == 0 {
entry.Attributes.Uid = uid
}
request := &filer_pb.CreateEntryRequest{
Directory: string(dir),
Entry: entry.GetEntry(),
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
if entry.Attributes.Gid == 0 {
entry.Attributes.Gid = gid
}
entry.Attributes.Mtime = time.Now().Unix()
}
glog.V(4).Infof("%s set chunks: %v", fileFullPath, len(entry.GetChunks()))
glog.V(4).Infof("%s set chunks: %v", fileFullPath, len(entry.GetChunks()))
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(entry.GetChunks())
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(entry.GetChunks())
chunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
chunks, manifestErr := filer.MaybeManifestize(wfs.saveDataAsChunk(fileFullPath), chunks)
if manifestErr != nil {
// not good, but should be ok
glog.V(0).Infof("MaybeManifestize: %v", manifestErr)
}
entry.Chunks = append(chunks, manifestChunks...)
chunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
chunks, manifestErr := filer.MaybeManifestize(wfs.saveDataAsChunk(fileFullPath), chunks)
if manifestErr != nil {
// not good, but should be ok
glog.V(0).Infof("MaybeManifestize: %v", manifestErr)
}
entry.Chunks = append(chunks, manifestChunks...)
wfs.mapPbIdFromLocalToFiler(request.Entry)
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
// Clone the proto entry for the filer request so that mapPbIdFromLocalToFiler
// does not mutate the file handle's live entry. Without the clone, a concurrent
// Lookup can observe filer-side uid/gid on the file handle entry and return it
// to the kernel, which caches it and then rejects opens by the local user.
requestEntry := proto.Clone(entry.GetEntry()).(*filer_pb.Entry)
request := &filer_pb.CreateEntryRequest{
Directory: string(dir),
Entry: requestEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
glog.Errorf("fh flush create %s: %v", fileFullPath, err)
return fmt.Errorf("fh flush create %s: %v", fileFullPath, err)
}
wfs.mapPbIdFromLocalToFiler(request.Entry)
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(string(dir), request.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("flush %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
}
resp, err := wfs.streamCreateEntry(context.Background(), request)
if err != nil {
glog.Errorf("fh flush create %s: %v", fileFullPath, err)
return fmt.Errorf("fh flush create %s: %v", fileFullPath, err)
}
return nil
})
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(string(dir), request.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("flush %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
}
if err == nil {
fh.dirtyMetadata = false
+13 -14
View File
@@ -21,8 +21,15 @@ func (wfs *WFS) AcquireHandle(inode uint64, flags, uid, gid uint32) (fileHandle
if wormEnforced, _ := wfs.wormEnforcedForEntry(path, entry); wormEnforced && flags&fuse.O_ANYWRITE != 0 {
return nil, fuse.EPERM
}
// Check unix permission bits for the requested access mode.
if entry != nil && entry.Attributes != nil {
if mask := openFlagsToAccessMask(flags); mask != 0 && !hasAccess(uid, gid, entry.Attributes.Uid, entry.Attributes.Gid, entry.Attributes.FileMode, mask) {
return nil, fuse.EACCES
}
}
// need to AcquireFileHandle again to ensure correct handle counter
fileHandle = wfs.fhMap.AcquireFileHandle(wfs, inode, entry)
fileHandle.RememberPath(path)
}
return
}
@@ -43,22 +50,14 @@ func (wfs *WFS) ReleaseHandle(handleId FileHandleId) {
if fhToRelease != nil && fhToRelease.asyncFlushPending {
done := make(chan struct{})
wfs.pendingAsyncFlush[fhToRelease.inode] = done
// Add(1) while holding the mutex so WaitForAsyncFlush cannot
// observe a zero counter and close the channel before we send.
wfs.asyncFlushWg.Add(1)
wfs.pendingAsyncFlushMu.Unlock()
wfs.asyncFlushWg.Add(1)
go func() {
defer wfs.asyncFlushWg.Done()
defer func() {
// Remove from fhMap first (so AcquireFileHandle creates a fresh handle).
wfs.fhMap.RemoveFileHandle(fhToRelease.fh, fhToRelease.inode)
// Then signal completion (unblocks waitForPendingAsyncFlush).
close(done)
wfs.pendingAsyncFlushMu.Lock()
delete(wfs.pendingAsyncFlush, fhToRelease.inode)
wfs.pendingAsyncFlushMu.Unlock()
}()
wfs.completeAsyncFlush(fhToRelease)
}()
// Send after unlock to avoid deadlock — workers acquire the
// same mutex during cleanup.
wfs.asyncFlushCh <- &asyncFlushItem{fh: fhToRelease, done: done}
return
}
wfs.pendingAsyncFlushMu.Unlock()
+26 -25
View File
@@ -60,8 +60,11 @@ func (wfs *WFS) Link(cancel <-chan struct{}, in *fuse.LinkIn, name string, out *
if len(oldEntry.HardLinkId) == 0 {
oldEntry.HardLinkId = filer.NewHardLinkId()
oldEntry.HardLinkCounter = 1
glog.V(4).Infof("Link: new HardLinkId %x for %s", oldEntry.HardLinkId, oldEntryPath)
}
oldEntry.HardLinkCounter++
glog.V(4).Infof("Link: %s -> %s/%s HardLinkId %x counter=%d",
oldEntryPath, newParentPath, name, oldEntry.HardLinkId, oldEntry.HardLinkCounter)
updateOldEntryRequest := &filer_pb.UpdateEntryRequest{
Directory: oldParentPath,
Entry: oldEntry,
@@ -86,25 +89,23 @@ func (wfs *WFS) Link(cancel <-chan struct{}, in *fuse.LinkIn, name string, out *
}
// apply changes to the filer, and also apply to local metaCache
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
wfs.mapPbIdFromLocalToFiler(request.Entry)
wfs.mapPbIdFromLocalToFiler(request.Entry)
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
updateResp, err := filer_pb.UpdateEntryWithResponse(context.Background(), client, updateOldEntryRequest)
if err != nil {
return err
}
ctx := context.Background()
updateResp, err := wfs.streamUpdateEntry(ctx, updateOldEntryRequest)
if err == nil {
updateEvent := updateResp.GetMetadataEvent()
if updateEvent == nil {
updateEvent = metadataUpdateEvent(oldParentPath, updateOldEntryRequest.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), updateEvent); applyErr != nil {
if applyErr := wfs.applyLocalMetadataEvent(ctx, updateEvent); applyErr != nil {
glog.Warningf("link %s: best-effort metadata apply failed: %v", oldEntryPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(oldParentPath))
}
createResp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
}
if err == nil {
var createResp *filer_pb.CreateEntryResponse
createResp, err = wfs.streamCreateEntry(ctx, request)
if err != nil {
// Rollback: restore original HardLinkId/Counter on the source entry
oldEntry.HardLinkId = origHardLinkId
@@ -114,26 +115,26 @@ func (wfs *WFS) Link(cancel <-chan struct{}, in *fuse.LinkIn, name string, out *
Entry: oldEntry,
Signatures: []int32{wfs.signature},
}
if _, rollbackErr := filer_pb.UpdateEntryWithResponse(context.Background(), client, rollbackReq); rollbackErr != nil {
if _, rollbackErr := wfs.streamUpdateEntry(ctx, rollbackReq); rollbackErr != nil {
glog.Warningf("link rollback %s: %v", oldEntryPath, rollbackErr)
}
return err
} else {
createEvent := createResp.GetMetadataEvent()
if createEvent == nil {
createEvent = metadataCreateEvent(string(newParentPath), request.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(ctx, createEvent); applyErr != nil {
glog.Warningf("link %s: best-effort metadata apply failed: %v", newParentPath.Child(name), applyErr)
wfs.inodeToPath.InvalidateChildrenCache(newParentPath)
}
}
createEvent := createResp.GetMetadataEvent()
if createEvent == nil {
createEvent = metadataCreateEvent(string(newParentPath), request.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), createEvent); applyErr != nil {
glog.Warningf("link %s: best-effort metadata apply failed: %v", newParentPath.Child(name), applyErr)
wfs.inodeToPath.InvalidateChildrenCache(newParentPath)
}
return nil
})
}
newEntryPath := newParentPath.Child(name)
// Map back to local uid/gid before writing attributes to the kernel.
wfs.mapPbIdFromFilerToLocal(request.Entry)
if err != nil {
glog.V(0).Infof("Link %v -> %s: %v", oldEntryPath, newEntryPath, err)
return fuse.EIO
+54 -58
View File
@@ -9,6 +9,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
// loopFlushDirtyMetadata periodically flushes dirty file metadata to the filer.
@@ -99,70 +100,65 @@ func (wfs *WFS) flushFileMetadata(fh *FileHandle) error {
glog.V(4).Infof("flushFileMetadata %s fh %d", fileFullPath, fh.fh)
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
entry := fh.GetEntry()
if entry == nil {
return nil
}
entry.Name = name
if entry.Attributes != nil {
entry.Attributes.Mtime = time.Now().Unix()
}
// Get current chunks - these include chunks that have been uploaded
// but not yet persisted to filer metadata
chunks := entry.GetChunks()
if len(chunks) == 0 {
return nil
}
// Separate manifest and non-manifest chunks
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(chunks)
// Compact chunks to remove fully overlapped ones
compactedChunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
// Try to create manifest chunks for large files
compactedChunks, manifestErr := filer.MaybeManifestize(wfs.saveDataAsChunk(fileFullPath), compactedChunks)
if manifestErr != nil {
glog.V(0).Infof("flushFileMetadata MaybeManifestize: %v", manifestErr)
}
entry.Chunks = append(compactedChunks, manifestChunks...)
request := &filer_pb.CreateEntryRequest{
Directory: string(dir),
Entry: entry.GetEntry(),
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
wfs.mapPbIdFromLocalToFiler(request.Entry)
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
return err
}
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(string(dir), request.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("flushFileMetadata %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
}
glog.V(3).Infof("flushed metadata for %s with %d chunks", fileFullPath, len(entry.GetChunks()))
entry := fh.GetEntry()
if entry == nil {
return nil
})
}
entry.Name = name
if entry.Attributes != nil {
entry.Attributes.Mtime = time.Now().Unix()
}
// Get current chunks - these include chunks that have been uploaded
// but not yet persisted to filer metadata
chunks := entry.GetChunks()
if len(chunks) == 0 {
return nil
}
// Separate manifest and non-manifest chunks
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(chunks)
// Compact chunks to remove fully overlapped ones
compactedChunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
// Try to create manifest chunks for large files
compactedChunks, manifestErr := filer.MaybeManifestize(wfs.saveDataAsChunk(fileFullPath), compactedChunks)
if manifestErr != nil {
glog.V(0).Infof("flushFileMetadata MaybeManifestize: %v", manifestErr)
}
entry.Chunks = append(compactedChunks, manifestChunks...)
// Clone the proto entry so mapPbIdFromLocalToFiler does not mutate the
// file handle's live entry (same race as in flushMetadataToFiler).
requestEntry := proto.Clone(entry.GetEntry()).(*filer_pb.Entry)
request := &filer_pb.CreateEntryRequest{
Directory: string(dir),
Entry: requestEntry,
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
wfs.mapPbIdFromLocalToFiler(request.Entry)
resp, err := wfs.streamCreateEntry(context.Background(), request)
if err != nil {
return err
}
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(string(dir), request.Entry)
}
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
glog.Warningf("flushFileMetadata %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
}
glog.V(3).Infof("flushed metadata for %s with %d chunks", fileFullPath, len(entry.GetChunks()))
// Note: We do NOT clear dirtyMetadata here because:
// 1. There may still be dirty pages in the write buffer
// 2. The file may receive more writes before close
+86 -49
View File
@@ -2,6 +2,7 @@ package mount
import (
"context"
"errors"
"fmt"
"io"
"strings"
@@ -14,6 +15,38 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
)
// 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) 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)
})
if err == nil || !errors.Is(err, ErrStreamTransport) {
return err // success or application error
}
glog.V(1).Infof("Rename %s => %s: stream failed, falling back to unary: %v", oldPath, newPath, err)
}
return wfs.WithFilerClient(true, func(client filer_pb.SeaweedFilerClient) error {
stream, streamErr := client.StreamRenameEntry(ctx, request)
if streamErr != nil {
return fmt.Errorf("dir AtomicRenameEntry %s => %s : %v", oldPath, newPath, streamErr)
}
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
return fmt.Errorf("dir Rename %s => %s receive: %v", oldPath, newPath, recvErr)
}
if err := wfs.handleRenameResponse(ctx, resp); err != nil {
return err
}
}
return nil
})
}
/** Rename a file
*
* If the target exists it should be atomically replaced. If
@@ -171,53 +204,60 @@ func (wfs *WFS) Rename(cancel <-chan struct{}, in *fuse.RenameIn, oldName string
glog.V(4).Infof("dir Rename %s => %s", oldPath, newPath)
// 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.
// 2. writebackCache — close() triggered async flush, handle released.
// The filer rename will fail with ENOENT unless we flush/wait first.
if inode, found := wfs.inodeToPath.GetInode(oldPath); found {
// Case 1: handle still open with deferred metadata — flush synchronously
// BEFORE any async flush interference.
if fh, ok := wfs.fhMap.FindFileHandle(inode); ok && fh.dirtyMetadata {
glog.V(4).Infof("dir Rename %s: flushing deferred metadata before rename", oldPath)
if flushStatus := wfs.doFlush(fh, oldEntry.Attributes.Uid, oldEntry.Attributes.Gid, false); flushStatus != fuse.OK {
glog.Warningf("dir Rename %s: flush before rename failed: %v", oldPath, flushStatus)
return flushStatus
}
}
// Case 2: handle already released, async flush may be in flight.
// Mark ALL handles for this inode as renamed so the async flush
// skips old-path metadata creation (which would re-insert the
// renamed entry into the meta cache after rename events clean it up).
wfs.fhMap.MarkInodeRenamed(inode)
wfs.waitForPendingAsyncFlush(inode)
} else if oldEntry != nil && oldEntry.Attributes != nil && oldEntry.Attributes.Inode != 0 {
// GetInode failed (Forget already removed the mapping), but the
// entry's stored inode can still identify pending async flushes.
inode = oldEntry.Attributes.Inode
wfs.fhMap.MarkInodeRenamed(inode)
wfs.waitForPendingAsyncFlush(inode)
}
// update remote filer
err := wfs.WithFilerClient(true, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request := &filer_pb.StreamRenameEntryRequest{
OldDirectory: string(oldDir),
OldName: oldName,
NewDirectory: string(newDir),
NewName: newName,
Signatures: []int32{wfs.signature},
}
request := &filer_pb.StreamRenameEntryRequest{
OldDirectory: string(oldDir),
OldName: oldName,
NewDirectory: string(newDir),
NewName: newName,
Signatures: []int32{wfs.signature},
}
stream, err := client.StreamRenameEntry(ctx, request)
if err != nil {
code = fuse.EIO
return fmt.Errorf("dir AtomicRenameEntry %s => %s : %v", oldPath, newPath, err)
}
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
} else {
if strings.Contains(recvErr.Error(), "not empty") {
code = fuse.Status(syscall.ENOTEMPTY)
} else if strings.Contains(recvErr.Error(), "not directory") {
code = fuse.ENOTDIR
}
return fmt.Errorf("dir Rename %s => %s receive: %v", oldPath, newPath, recvErr)
}
}
if err = wfs.handleRenameResponse(ctx, resp); err != nil {
glog.V(0).Infof("dir Rename %s => %s : %v", oldPath, newPath, err)
return err
}
}
return nil
})
ctx := context.Background()
err := wfs.doRename(ctx, request, oldPath, newPath)
if err != nil {
glog.V(0).Infof("Link: %v", err)
return
glog.V(0).Infof("Rename %s => %s: %v", oldPath, newPath, err)
// Map error strings to FUSE status codes. String matching is used
// instead of raw errno to stay portable across platforms (errno
// numeric values differ between Linux and macOS).
msg := err.Error()
if strings.Contains(msg, "not found") {
return fuse.Status(syscall.ENOENT)
} else if strings.Contains(msg, "not empty") {
return fuse.Status(syscall.ENOTEMPTY)
} else if strings.Contains(msg, "not directory") {
return fuse.ENOTDIR
}
return fuse.EIO
}
wfs.inodeToPath.TouchDirectory(oldDir)
wfs.inodeToPath.TouchDirectory(newDir)
@@ -253,12 +293,9 @@ func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamR
if entry := fh.GetEntry(); entry != nil {
entry.Name = newName
}
// Keep the saved async-flush path current so the fallback
// Keep the saved handle path current so any flush fallback
// after Forget uses the post-rename location, not the old one.
if fh.asyncFlushPending {
fh.asyncFlushDir = string(newParent)
fh.asyncFlushName = newName
}
fh.RememberPath(newPath)
}
// invalidate attr and data
// wfs.fuseServer.InodeNotify(sourceInode, 0, -1)
+66
View File
@@ -0,0 +1,66 @@
package mount
import (
"context"
"errors"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// streamCreateEntry routes a CreateEntryRequest through the streaming mux
// if available, falling back to a unary gRPC call on transport errors.
func (wfs *WFS) streamCreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
if wfs.streamMutate != nil && wfs.streamMutate.IsAvailable() {
resp, err := wfs.streamMutate.CreateEntry(ctx, req)
if err == nil || !errors.Is(err, ErrStreamTransport) {
return resp, err // success or application error — don't retry
}
glog.V(1).Infof("streamCreateEntry %s/%s: stream failed, falling back to unary: %v", req.Directory, req.Entry.Name, err)
}
var resp *filer_pb.CreateEntryResponse
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
var err error
resp, err = filer_pb.CreateEntryWithResponse(ctx, client, req)
return err
})
return resp, err
}
// streamUpdateEntry routes an UpdateEntryRequest through the streaming mux
// if available, falling back to a unary gRPC call on transport errors.
func (wfs *WFS) streamUpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
if wfs.streamMutate != nil && wfs.streamMutate.IsAvailable() {
resp, err := wfs.streamMutate.UpdateEntry(ctx, req)
if err == nil || !errors.Is(err, ErrStreamTransport) {
return resp, err
}
glog.V(1).Infof("streamUpdateEntry %s/%s: stream failed, falling back to unary: %v", req.Directory, req.Entry.Name, err)
}
var resp *filer_pb.UpdateEntryResponse
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
var err error
resp, err = client.UpdateEntry(ctx, req)
return err
})
return resp, err
}
// streamDeleteEntry routes a DeleteEntryRequest through the streaming mux
// if available, falling back to a unary gRPC call on transport errors.
func (wfs *WFS) streamDeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
if wfs.streamMutate != nil && wfs.streamMutate.IsAvailable() {
resp, err := wfs.streamMutate.DeleteEntry(ctx, req)
if err == nil || !errors.Is(err, ErrStreamTransport) {
return resp, err
}
glog.V(1).Infof("streamDeleteEntry %s/%s: stream failed, falling back to unary: %v", req.Directory, req.Name, err)
}
var resp *filer_pb.DeleteEntryResponse
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
var err error
resp, err = client.DeleteEntry(ctx, req)
return err
})
return resp, err
}
+506
View File
@@ -0,0 +1,506 @@
package mount
import (
"context"
"errors"
"fmt"
"io"
"sync"
"sync/atomic"
"syscall"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
grpcMetadata "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// streamMutateError is returned when the server reports a structured errno.
// It is also used by helpers to distinguish application errors (don't retry
// on unary fallback) from transport errors (do retry).
type streamMutateError struct {
msg string
errno syscall.Errno
}
func (e *streamMutateError) Error() string { return e.msg }
func (e *streamMutateError) Errno() syscall.Errno { return e.errno }
// ErrStreamTransport is a sentinel error type for transport-level stream
// failures (disconnects, send errors). Callers use errors.Is to decide
// whether to fall back to unary RPCs.
var ErrStreamTransport = errors.New("stream transport error")
// streamMutateMux multiplexes filer mutation RPCs (create, update, delete,
// rename) over a single bidirectional gRPC stream. Multiple goroutines can
// call the mutation methods concurrently; requests are serialized through
// sendCh and responses are dispatched back via per-request channels.
type streamMutateMux struct {
wfs *WFS
mu sync.Mutex // protects stream, cancel, grpcConn, closed, stopSend, generation
stream filer_pb.SeaweedFiler_StreamMutateEntryClient
cancel context.CancelFunc
grpcConn *grpc.ClientConn // dedicated connection, closed on stream teardown
closed bool
disabled bool // permanently disabled if filer doesn't support the RPC
stopSend chan struct{} // closed to signal the current sendLoop to exit
generation uint64 // incremented each time a new stream is created
nextID atomic.Uint64
// pending maps request_id → response channel. The recvLoop dispatches
// each response to the correct waiter. For rename (multi-response),
// the channel receives multiple messages until is_last=true.
pending sync.Map // map[uint64]chan *filer_pb.StreamMutateEntryResponse
sendCh chan *streamMutateReq
recvDone chan struct{} // closed when recvLoop exits
}
type streamMutateReq struct {
req *filer_pb.StreamMutateEntryRequest
errCh chan error // send error feedback
gen uint64 // stream generation this request targets
}
func newStreamMutateMux(wfs *WFS) *streamMutateMux {
return &streamMutateMux{
wfs: wfs,
sendCh: make(chan *streamMutateReq, 512),
}
}
// CreateEntry sends a CreateEntryRequest over the stream and waits for the response.
func (m *streamMutateMux) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
resp, err := m.doUnary(ctx, &filer_pb.StreamMutateEntryRequest{
Request: &filer_pb.StreamMutateEntryRequest_CreateRequest{CreateRequest: req},
})
if err != nil {
return nil, err
}
r, ok := resp.Response.(*filer_pb.StreamMutateEntryResponse_CreateResponse)
if !ok {
return nil, fmt.Errorf("unexpected response type %T", resp.Response)
}
// Check nested error fields (same logic as CreateEntryWithResponse).
cr := r.CreateResponse
if cr.ErrorCode != filer_pb.FilerError_OK {
if sentinel := filer_pb.FilerErrorToSentinel(cr.ErrorCode); sentinel != nil {
return nil, fmt.Errorf("CreateEntry %s/%s: %w", req.Directory, req.Entry.Name, sentinel)
}
return nil, &streamMutateError{msg: cr.Error, errno: syscall.EIO}
}
if cr.Error != "" {
return nil, &streamMutateError{msg: cr.Error, errno: syscall.EIO}
}
return cr, nil
}
// UpdateEntry sends an UpdateEntryRequest over the stream and waits for the response.
func (m *streamMutateMux) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
resp, err := m.doUnary(ctx, &filer_pb.StreamMutateEntryRequest{
Request: &filer_pb.StreamMutateEntryRequest_UpdateRequest{UpdateRequest: req},
})
if err != nil {
return nil, err
}
if r, ok := resp.Response.(*filer_pb.StreamMutateEntryResponse_UpdateResponse); ok {
return r.UpdateResponse, nil
}
return nil, fmt.Errorf("unexpected response type %T", resp.Response)
}
// DeleteEntry sends a DeleteEntryRequest over the stream and waits for the response.
func (m *streamMutateMux) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
resp, err := m.doUnary(ctx, &filer_pb.StreamMutateEntryRequest{
Request: &filer_pb.StreamMutateEntryRequest_DeleteRequest{DeleteRequest: req},
})
if err != nil {
return nil, err
}
r, ok := resp.Response.(*filer_pb.StreamMutateEntryResponse_DeleteResponse)
if !ok {
return nil, fmt.Errorf("unexpected response type %T", resp.Response)
}
// Check nested error field.
if r.DeleteResponse.Error != "" {
return nil, &streamMutateError{msg: r.DeleteResponse.Error, errno: syscall.EIO}
}
return r.DeleteResponse, nil
}
// Rename sends a StreamRenameEntryRequest over the stream and collects all
// response events until is_last=true. The callback is invoked for each
// intermediate rename event (same as the current StreamRenameEntry recv loop).
func (m *streamMutateMux) Rename(ctx context.Context, req *filer_pb.StreamRenameEntryRequest, onEvent func(*filer_pb.StreamRenameEntryResponse) error) error {
gen, err := m.ensureStream()
if err != nil {
return fmt.Errorf("%w: %v", ErrStreamTransport, err)
}
id := m.nextID.Add(1)
ch := make(chan *filer_pb.StreamMutateEntryResponse, 64)
m.pending.Store(id, ch)
defer m.pending.Delete(id)
sendReq := &streamMutateReq{
req: &filer_pb.StreamMutateEntryRequest{
RequestId: id,
Request: &filer_pb.StreamMutateEntryRequest_RenameRequest{RenameRequest: req},
},
errCh: make(chan error, 1),
gen: gen,
}
select {
case m.sendCh <- sendReq:
case <-ctx.Done():
return ctx.Err()
}
select {
case err := <-sendReq.errCh:
if err != nil {
return fmt.Errorf("rename send: %w: %v", ErrStreamTransport, err)
}
case <-ctx.Done():
return ctx.Err()
}
// Collect rename events until is_last=true.
for {
select {
case resp, ok := <-ch:
if !ok {
return fmt.Errorf("rename recv: %w: stream closed", ErrStreamTransport)
}
if r, ok := resp.Response.(*filer_pb.StreamMutateEntryResponse_RenameResponse); ok {
if r.RenameResponse != nil && r.RenameResponse.EventNotification != nil {
if err := onEvent(r.RenameResponse); err != nil {
return err
}
}
}
if resp.IsLast {
if resp.Error != "" {
return &streamMutateError{
msg: resp.Error,
errno: syscall.Errno(resp.Errno),
}
}
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// doUnary sends a single-response request and waits for the reply.
func (m *streamMutateMux) doUnary(ctx context.Context, req *filer_pb.StreamMutateEntryRequest) (*filer_pb.StreamMutateEntryResponse, error) {
gen, err := m.ensureStream()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrStreamTransport, err)
}
id := m.nextID.Add(1)
req.RequestId = id
ch := make(chan *filer_pb.StreamMutateEntryResponse, 1)
m.pending.Store(id, ch)
defer m.pending.Delete(id)
sendReq := &streamMutateReq{
req: req,
errCh: make(chan error, 1),
gen: gen,
}
select {
case m.sendCh <- sendReq:
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case err := <-sendReq.errCh:
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrStreamTransport, err)
}
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case resp, ok := <-ch:
if !ok {
return nil, fmt.Errorf("%w: stream closed", ErrStreamTransport)
}
if resp.Error != "" {
return nil, &streamMutateError{
msg: resp.Error,
errno: syscall.Errno(resp.Errno),
}
}
return resp, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// ensureStream opens the bidi stream if not already open. It returns the
// stream generation so callers can tag outgoing requests.
func (m *streamMutateMux) ensureStream() (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return 0, fmt.Errorf("stream mux is closed")
}
if m.disabled {
return 0, fmt.Errorf("StreamMutateEntry not supported by filer")
}
if m.stream != nil {
return m.generation, nil
}
// Wait for prior generation's recvLoop to fully tear down before opening
// a new stream. This guarantees all pending waiters from the old stream
// have been failed before we create a new generation.
if m.recvDone != nil {
done := m.recvDone
m.mu.Unlock()
<-done
m.mu.Lock()
// Re-check after reacquiring the lock.
if m.closed {
return 0, fmt.Errorf("stream mux is closed")
}
if m.disabled {
return 0, fmt.Errorf("StreamMutateEntry not supported by filer")
}
if m.stream != nil {
return m.generation, nil
}
}
var stream filer_pb.SeaweedFiler_StreamMutateEntryClient
err := m.openStream(&stream)
if err != nil {
if s, ok := status.FromError(err); ok && s.Code() == codes.Unimplemented {
m.disabled = true
glog.V(0).Infof("filer does not support StreamMutateEntry, falling back to unary RPCs")
}
return 0, err
}
m.generation++
m.stream = stream
m.stopSend = make(chan struct{})
recvDone := make(chan struct{})
m.recvDone = recvDone
gen := m.generation
go m.sendLoop(stream, m.stopSend, gen)
go m.recvLoop(stream, gen, recvDone)
return gen, nil
}
func (m *streamMutateMux) openStream(out *filer_pb.SeaweedFiler_StreamMutateEntryClient) error {
i := atomic.LoadInt32(&m.wfs.option.filerIndex)
n := int32(len(m.wfs.option.FilerAddresses))
var lastErr error
for x := int32(0); x < n; x++ {
idx := (i + x) % n
filerGrpcAddress := m.wfs.option.FilerAddresses[idx].ToGrpcAddress()
ctx := context.Background()
if m.wfs.signature != 0 {
ctx = grpcMetadata.AppendToOutgoingContext(ctx, "sw-client-id", fmt.Sprintf("%d", m.wfs.signature))
}
grpcConn, err := pb.GrpcDial(ctx, filerGrpcAddress, false, m.wfs.option.GrpcDialOption)
if err != nil {
lastErr = fmt.Errorf("stream dial %s: %v", filerGrpcAddress, err)
continue
}
client := filer_pb.NewSeaweedFilerClient(grpcConn)
streamCtx, cancel := context.WithCancel(ctx)
stream, err := client.StreamMutateEntry(streamCtx)
if err != nil {
cancel()
grpcConn.Close()
lastErr = err
// Unimplemented means all filers lack it — stop rotating.
if s, ok := status.FromError(err); ok && s.Code() == codes.Unimplemented {
return err
}
continue
}
atomic.StoreInt32(&m.wfs.option.filerIndex, idx)
m.cancel = cancel
m.grpcConn = grpcConn
*out = stream
return nil
}
return lastErr
}
func (m *streamMutateMux) sendLoop(stream filer_pb.SeaweedFiler_StreamMutateEntryClient, stop <-chan struct{}, gen uint64) {
defer m.drainSendCh()
for {
select {
case req, ok := <-m.sendCh:
if !ok {
return // defensive: sendCh should not be closed
}
if req.gen != gen {
req.errCh <- fmt.Errorf("%w: stream generation mismatch", ErrStreamTransport)
continue
}
err := stream.Send(req.req)
req.errCh <- err
if err != nil {
m.teardownStream(gen)
return
}
case <-stop:
return
}
}
}
func (m *streamMutateMux) recvLoop(stream filer_pb.SeaweedFiler_StreamMutateEntryClient, gen uint64, recvDone chan struct{}) {
defer func() {
m.failAllPending()
close(recvDone)
}()
for {
resp, err := stream.Recv()
if err != nil {
if err != io.EOF {
glog.V(1).Infof("stream mutate recv error (gen=%d): %v", gen, err)
}
m.teardownStream(gen)
return
}
if ch, ok := m.pending.Load(resp.RequestId); ok {
ch.(chan *filer_pb.StreamMutateEntryResponse) <- resp
// For single-response ops, the caller deletes from pending after recv.
// For rename, the caller collects until is_last.
}
}
}
// teardownStream cleans up the stream for the given generation. It is safe to
// call from both sendLoop and recvLoop; only the first call for a given
// generation takes effect (idempotent via generation + nil-stream check).
func (m *streamMutateMux) teardownStream(gen uint64) {
m.mu.Lock()
if m.generation != gen || m.stream == nil {
m.mu.Unlock()
return
}
m.stream = nil
if m.stopSend != nil {
close(m.stopSend)
m.stopSend = nil
}
if m.cancel != nil {
m.cancel()
m.cancel = nil
}
conn := m.grpcConn
m.grpcConn = nil
m.mu.Unlock()
// Do NOT call failAllPending here — recvLoop is the sole owner of
// pending channel teardown. This avoids a race where teardownStream
// closes a channel that recvLoop is about to send on.
if conn != nil {
conn.Close()
}
}
// failAllPending closes all pending response channels, causing waiters to
// receive ok=false. It is idempotent: entries are deleted before channels are
// closed, so concurrent calls cannot double-close.
func (m *streamMutateMux) failAllPending() {
var channels []chan *filer_pb.StreamMutateEntryResponse
m.pending.Range(func(key, value any) bool {
m.pending.Delete(key)
channels = append(channels, value.(chan *filer_pb.StreamMutateEntryResponse))
return true
})
for _, ch := range channels {
close(ch)
}
}
// drainSendCh drains buffered requests from sendCh, sending an error to each
// request's errCh so callers don't block. Called by sendLoop's defer on exit
// and by Close for any stragglers.
func (m *streamMutateMux) drainSendCh() {
for {
select {
case req, ok := <-m.sendCh:
if !ok {
return // defensive: sendCh should not be closed
}
req.errCh <- fmt.Errorf("%w: stream shutting down", ErrStreamTransport)
default:
return
}
}
}
// IsAvailable returns true if the stream mux is usable (not permanently disabled).
func (m *streamMutateMux) IsAvailable() bool {
m.mu.Lock()
defer m.mu.Unlock()
return !m.disabled
}
// Close shuts down the stream. Called during unmount after all flushes complete.
func (m *streamMutateMux) Close() {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return
}
m.closed = true
stream := m.stream
m.stream = nil // prevent teardownStream from acting after Close
cancel := m.cancel
m.cancel = nil
grpcConn := m.grpcConn
m.grpcConn = nil
recvDone := m.recvDone
if m.stopSend != nil {
close(m.stopSend)
m.stopSend = nil
}
m.mu.Unlock()
// CloseSend triggers EOF on recvLoop; cancel ensures Recv unblocks
// even if the transport is broken.
if stream != nil {
stream.CloseSend()
}
if cancel != nil {
cancel()
}
// Wait for recvLoop to finish — it calls failAllPending on exit.
if recvDone != nil {
<-recvDone
}
if grpcConn != nil {
grpcConn.Close()
}
// Drain any remaining requests buffered in sendCh. sendLoop's defer
// drain handles most items, but stragglers enqueued during shutdown
// (between ensureStream and the sendCh send) are caught here.
// sendCh is intentionally left open to prevent send-on-closed panics.
m.drainSendCh()
}
+7 -12
View File
@@ -2,7 +2,6 @@ package mount
import (
"context"
"fmt"
"os"
"syscall"
"time"
@@ -47,16 +46,10 @@ func (wfs *WFS) Symlink(cancel <-chan struct{}, header *fuse.InHeader, target st
SkipCheckParentDirectory: true,
}
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
wfs.mapPbIdFromLocalToFiler(request.Entry)
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
return fmt.Errorf("symlink %s: %v", entryFullPath, err)
}
wfs.mapPbIdFromLocalToFiler(request.Entry)
resp, err := wfs.streamCreateEntry(context.Background(), request)
if err == nil {
event := resp.GetMetadataEvent()
if event == nil {
event = metadataCreateEvent(string(dirPath), request.Entry)
@@ -65,9 +58,11 @@ func (wfs *WFS) Symlink(cancel <-chan struct{}, header *fuse.InHeader, target st
glog.Warningf("symlink %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(dirPath)
}
}
// Map back to local uid/gid before writing to the kernel.
wfs.mapPbIdFromFilerToLocal(request.Entry)
return nil
})
if err != nil {
glog.V(0).Infof("Symlink %s => %s: %v", entryFullPath, target, err)
return fuse.EIO
-25
View File
@@ -15,28 +15,3 @@ import "github.com/seaweedfs/go-fuse/v2/fuse"
func (wfs *WFS) Fallocate(cancel <-chan struct{}, in *fuse.FallocateIn) (code fuse.Status) {
return fuse.ENOSYS
}
func (wfs *WFS) GetLk(cancel <-chan struct{}, in *fuse.LkIn, out *fuse.LkOut) (code fuse.Status) {
return fuse.ENOSYS
}
func (wfs *WFS) SetLk(cancel <-chan struct{}, in *fuse.LkIn) (code fuse.Status) {
return fuse.ENOSYS
}
func (wfs *WFS) SetLkw(cancel <-chan struct{}, in *fuse.LkIn) (code fuse.Status) {
return fuse.ENOSYS
}
/**
* Check file access permissions
*
* This will be called for the access() system call. If the
* 'default_permissions' mount option is given, this method is not
* called.
*
* This method is not called under Linux kernel versions 2.4.x
*/
func (wfs *WFS) Access(cancel <-chan struct{}, input *fuse.AccessIn) (code fuse.Status) {
return fuse.ENOSYS
}
+13 -18
View File
@@ -15,23 +15,20 @@ func (wfs *WFS) saveEntry(path util.FullPath, entry *filer_pb.Entry) (code fuse.
parentDir, _ := path.DirAndName()
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
wfs.mapPbIdFromLocalToFiler(entry)
defer wfs.mapPbIdFromFilerToLocal(entry)
wfs.mapPbIdFromLocalToFiler(entry)
defer wfs.mapPbIdFromFilerToLocal(entry)
request := &filer_pb.UpdateEntryRequest{
Directory: parentDir,
Entry: entry,
Signatures: []int32{wfs.signature},
}
glog.V(1).Infof("save entry: %v", request)
resp, err := filer_pb.UpdateEntryWithResponse(context.Background(), client, request)
if err != nil {
return fmt.Errorf("UpdateEntry dir %s: %v", path, err)
}
request := &filer_pb.UpdateEntryRequest{
Directory: parentDir,
Entry: entry,
Signatures: []int32{wfs.signature},
}
glog.V(1).Infof("save entry: %v", request)
resp, err := wfs.streamUpdateEntry(context.Background(), request)
if err != nil {
err = fmt.Errorf("UpdateEntry dir %s: %v", path, err)
} else {
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(parentDir, entry)
@@ -40,9 +37,7 @@ func (wfs *WFS) saveEntry(path util.FullPath, entry *filer_pb.Entry) (code fuse.
glog.Warningf("saveEntry %s: best-effort metadata apply failed: %v", path, applyErr)
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(parentDir))
}
return nil
})
}
if err != nil {
// glog.V(0).Infof("saveEntry %s: %v", path, err)
fuseStatus := grpcErrorToFuseStatus(err)
+224 -1
View File
@@ -249,7 +249,8 @@ func IsRetriableError(code int16) bool {
return GetErrorInfo(code).Retriable
}
// BuildErrorResponse builds a standard Kafka error response
// BuildErrorResponse builds a standard Kafka error response (2-byte error code only).
// Prefer BuildAPIErrorResponse for API-aware error bodies.
func BuildErrorResponse(correlationID uint32, errorCode int16) []byte {
response := make([]byte, 0, 8)
@@ -264,6 +265,228 @@ func BuildErrorResponse(correlationID uint32, errorCode int16) []byte {
return response
}
// BuildAPIErrorResponse builds a minimal-but-valid error response body whose
// layout matches the schema the client expects for the given API key and
// version. The correlation ID and header-level tagged fields are NOT included
// (those are added by writeResponseWithHeader).
func BuildAPIErrorResponse(apiKey, apiVersion uint16, errorCode int16) []byte {
ec := make([]byte, 2)
binary.BigEndian.PutUint16(ec, uint16(errorCode))
throttle := []byte{0, 0, 0, 0} // throttle_time_ms = 0
emptyArr := []byte{0, 0, 0, 0} // regular array length = 0
nullStr := []byte{0xFF, 0xFF} // nullable string = null
emptyStr := []byte{0, 0} // string length = 0
switch APIKey(apiKey) {
// --- error_code is the first body field -----------------------------------
case APIKeyApiVersions:
// error_code(2) + api_keys_array + [throttle_time_ms v1+] [+ tagged_fields v3+]
buf := append([]byte{}, ec...)
if apiVersion >= 3 {
buf = append(buf, 1) // compact array length=0 (varint 1 = 0+1)
} else {
buf = append(buf, emptyArr...)
}
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
if apiVersion >= 3 {
buf = append(buf, 0) // body-level tagged fields
}
return buf
// --- throttle_time_ms(4) + error_code(2) + trailing fields ----------------
case APIKeyFindCoordinator:
// [throttle v1+] + error_code + error_msg + node_id + host + port
buf := make([]byte, 0, 24)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
buf = append(buf, ec...)
buf = append(buf, nullStr...) // error_message
buf = append(buf, 0xFF, 0xFF, 0xFF, 0xFF) // node_id = -1
buf = append(buf, emptyStr...) // host
buf = append(buf, 0, 0, 0, 0) // port = 0
return buf
case APIKeyJoinGroup:
// [throttle v2+] + error_code + generation_id + protocol_name + leader + member_id + members[]
buf := make([]byte, 0, 24)
if apiVersion >= 2 {
buf = append(buf, throttle...)
}
buf = append(buf, ec...)
buf = append(buf, 0xFF, 0xFF, 0xFF, 0xFF) // generation_id = -1
buf = append(buf, nullStr...) // protocol_name
buf = append(buf, emptyStr...) // leader
buf = append(buf, emptyStr...) // member_id
buf = append(buf, emptyArr...) // members = []
return buf
case APIKeySyncGroup:
// [throttle v1+] + error_code + assignment
buf := make([]byte, 0, 12)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
buf = append(buf, ec...)
buf = append(buf, 0, 0, 0, 0) // assignment bytes length = 0
return buf
case APIKeyHeartbeat:
// [throttle v1+] + error_code
buf := make([]byte, 0, 8)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
buf = append(buf, ec...)
return buf
case APIKeyLeaveGroup:
// [throttle v1+] + error_code [+ members v3+]
buf := make([]byte, 0, 12)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
buf = append(buf, ec...)
if apiVersion >= 3 {
buf = append(buf, emptyArr...) // members = []
}
return buf
case APIKeyInitProducerId:
// throttle + error_code + producer_id + producer_epoch
buf := make([]byte, 0, 18)
buf = append(buf, throttle...)
buf = append(buf, ec...)
buf = append(buf, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF) // producer_id = -1
buf = append(buf, 0xFF, 0xFF) // producer_epoch = -1
return buf
case APIKeyListGroups:
// [throttle v1+] + error_code + groups[]
buf := make([]byte, 0, 12)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
buf = append(buf, ec...)
buf = append(buf, emptyArr...) // groups = []
return buf
case APIKeyDescribeCluster:
// throttle + error_code + error_msg + cluster_id + controller_id + brokers[] + cluster_authorized_operations
buf := make([]byte, 0, 24)
buf = append(buf, throttle...)
buf = append(buf, ec...)
buf = append(buf, nullStr...) // error_message
buf = append(buf, emptyStr...) // cluster_id
buf = append(buf, 0xFF, 0xFF, 0xFF, 0xFF) // controller_id = -1
buf = append(buf, emptyArr...) // brokers = []
buf = append(buf, 0, 0, 0, 0) // cluster_authorized_operations
return buf
// --- array-based responses (no top-level error_code) ----------------------
case APIKeyProduce:
// topics[] + [throttle v1+]
buf := append([]byte{}, emptyArr...)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
return buf
case APIKeyFetch:
// [throttle v1+] [+ error_code + session_id v7+] + topics[]
buf := make([]byte, 0, 16)
if apiVersion >= 1 {
buf = append(buf, throttle...)
}
if apiVersion >= 7 {
buf = append(buf, ec...) // error_code
buf = append(buf, 0, 0, 0, 0) // session_id = 0
}
buf = append(buf, emptyArr...) // topics = []
return buf
case APIKeyMetadata:
// [throttle v3+] + brokers[] + [cluster_id v2+] + [controller_id v1+] + topics[]
buf := make([]byte, 0, 24)
if apiVersion >= 3 {
buf = append(buf, throttle...)
}
buf = append(buf, emptyArr...) // brokers = []
if apiVersion >= 2 {
buf = append(buf, nullStr...) // cluster_id
}
if apiVersion >= 1 {
buf = append(buf, 0xFF, 0xFF, 0xFF, 0xFF) // controller_id = -1
}
buf = append(buf, emptyArr...) // topics = []
return buf
case APIKeyListOffsets:
// [throttle v2+] + topics[]
buf := make([]byte, 0, 8)
if apiVersion >= 2 {
buf = append(buf, throttle...)
}
buf = append(buf, emptyArr...)
return buf
case APIKeyOffsetCommit:
// [throttle v3+] + topics[]
buf := make([]byte, 0, 8)
if apiVersion >= 3 {
buf = append(buf, throttle...)
}
buf = append(buf, emptyArr...)
return buf
case APIKeyOffsetFetch:
// [throttle v3+] + topics[] [+ error_code v2+]
buf := make([]byte, 0, 12)
if apiVersion >= 3 {
buf = append(buf, throttle...)
}
buf = append(buf, emptyArr...) // topics = []
if apiVersion >= 2 {
buf = append(buf, ec...) // error_code
}
return buf
case APIKeyCreateTopics:
// throttle + topics[]
buf := append([]byte{}, throttle...)
buf = append(buf, emptyArr...)
return buf
case APIKeyDeleteTopics:
// throttle + topics[]
buf := append([]byte{}, throttle...)
buf = append(buf, emptyArr...)
return buf
case APIKeyDescribeGroups:
// throttle + groups[]
buf := append([]byte{}, throttle...)
buf = append(buf, emptyArr...)
return buf
case APIKeyDescribeConfigs:
// throttle + resources[]
buf := append([]byte{}, throttle...)
buf = append(buf, emptyArr...)
return buf
default:
// Unknown API — emit just the error code as a best-effort fallback
return append([]byte{}, ec...)
}
}
// BuildErrorResponseWithMessage builds a Kafka error response with error message
func BuildErrorResponseWithMessage(correlationID uint32, errorCode int16, message string) []byte {
response := BuildErrorResponse(correlationID, errorCode)
+79 -26
View File
@@ -626,14 +626,21 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
}
// Send this response
var respBody []byte
if readyResp.err != nil {
glog.Errorf("[%s] Error processing correlation=%d: %v", connectionID, readyResp.correlationID, readyResp.err)
// Build an API-versioned error response so the body matches
// the schema the client expects for this API key/version.
// A generic 2-byte error code would corrupt the protocol
// stream for APIs that start with throttle_time or arrays.
respBody = BuildAPIErrorResponse(readyResp.apiKey, readyResp.apiVersion, ErrorCodeUnknownServerError)
} else {
if writeErr := h.writeResponseWithHeader(w, readyResp.correlationID, readyResp.apiKey, readyResp.apiVersion, readyResp.response, timeoutConfig.WriteTimeout); writeErr != nil {
glog.Errorf("[%s] Response writer WRITE ERROR correlation=%d: %v - EXITING", connectionID, readyResp.correlationID, writeErr)
correlationQueueMu.Unlock()
return
}
respBody = readyResp.response
}
if writeErr := h.writeResponseWithHeader(w, readyResp.correlationID, readyResp.apiKey, readyResp.apiVersion, respBody, timeoutConfig.WriteTimeout); writeErr != nil {
glog.Errorf("[%s] Response writer WRITE ERROR correlation=%d: %v - EXITING", connectionID, readyResp.correlationID, writeErr)
correlationQueueMu.Unlock()
return
}
// Remove from pending and advance
@@ -689,7 +696,10 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
// Connection closed, stop processing
return
case <-time.After(5 * time.Second):
glog.Warningf("[%s] Control plane: timeout sending response correlation=%d", connectionID, req.correlationID)
// responseChan stuck — cancel context to tear down connection.
// The orphaned correlationID would stall the ordered writer permanently.
glog.Warningf("[%s] Control plane: timeout sending response correlation=%d, tearing down connection", connectionID, req.correlationID)
cancel()
}
case <-ctx.Done():
// Context cancelled, drain remaining requests before exiting
@@ -767,7 +777,10 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
// Connection closed, stop processing
return
case <-time.After(5 * time.Second):
glog.Warningf("[%s] Data plane: timeout sending response correlation=%d", connectionID, req.correlationID)
// responseChan stuck — cancel context to tear down connection.
// The orphaned correlationID would stall the ordered writer permanently.
glog.Warningf("[%s] Data plane: timeout sending response correlation=%d, tearing down connection", connectionID, req.correlationID)
cancel()
}
case <-ctx.Done():
// Context cancelled, drain remaining requests before exiting
@@ -804,13 +817,18 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
}()
defer func() {
// Close channels in correct order to avoid panics
// 1. Close input channels to stop accepting new requests
// Cancel context FIRST so the response writer (and any worker stuck
// in an inner select) sees ctx.Done() and can exit. Previously
// cancel() ran in a separate defer registered earlier (LIFO: later
// defer runs first), so wg.Wait() below would deadlock waiting for
// the response writer which was itself waiting for ctx.Done().
cancel()
// Close input channels to stop accepting new requests
close(controlChan)
close(dataChan)
// 2. Wait for worker goroutines to finish processing and sending responses
// Wait for worker goroutines to finish processing and sending responses
wg.Wait()
// 3. NOW close responseChan to signal response writer to exit
// NOW close responseChan (safe — all senders have exited)
close(responseChan)
}()
@@ -872,14 +890,21 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
if err := conn.SetReadDeadline(time.Now().Add(timeoutConfig.ReadTimeout)); err != nil {
}
// Read the message
// OPTIMIZATION: Use buffer pool to reduce GC pressure (was 1MB/sec at 1000 req/s)
messageBuf := mem.Allocate(int(size))
defer mem.Free(messageBuf)
if _, err := io.ReadFull(r, messageBuf); err != nil {
// Read the message into a pooled buffer, then copy to owned memory
// and return the pool buffer immediately. The previous code used
// defer mem.Free which accumulated one deferred free per iteration,
// leaking pool buffers for the connection lifetime and risking
// use-after-free when the defers ran before processing goroutines
// finished draining.
poolBuf := mem.Allocate(int(size))
if _, err := io.ReadFull(r, poolBuf); err != nil {
mem.Free(poolBuf)
_ = HandleTimeoutError(err, "read") // errorCode
return fmt.Errorf("read message: %w", err)
}
messageBuf := make([]byte, int(size))
copy(messageBuf, poolBuf)
mem.Free(poolBuf)
// Parse at least the basic header to get API key and correlation ID
if len(messageBuf) < 8 {
@@ -898,6 +923,13 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
if writeErr != nil {
return fmt.Errorf("build error response: %w", writeErr)
}
// Add to correlation queue BEFORE sending to responseChan so the
// response writer can match and send it. Without this the response
// sits in pendingResponses forever and the client hangs.
correlationQueueMu.Lock()
correlationQueue = append(correlationQueue, correlationID)
correlationQueueMu.Unlock()
// Send error response through response queue to maintain sequential ordering
select {
case responseChan <- &kafkaResponse{
@@ -1016,28 +1048,49 @@ func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error {
// Route to appropriate channel based on API key
var targetChan chan *kafkaRequest
if apiKey == 2 { // ListOffsets
}
if isDataPlaneAPI(apiKey) {
targetChan = dataChan
} else {
targetChan = controlChan
}
// Only add to correlation queue AFTER successful channel send
// If we add before and the channel blocks, the correlation ID is in the queue
// but the request never gets processed, causing response writer deadlock
// Add correlation ID BEFORE channel send to prevent race condition:
// If we add after, the processor can finish and send the response before the
// ID is in the queue. The response writer then can't match the response to a
// queue entry, causing a deadlock for sequential clients (e.g., Sarama).
// If the channel send fails, we send an error response so the response writer
// can advance past this entry.
correlationQueueMu.Lock()
correlationQueue = append(correlationQueue, correlationID)
correlationQueueMu.Unlock()
select {
case targetChan <- req:
// Request queued successfully - NOW add to correlation tracking
correlationQueueMu.Lock()
correlationQueue = append(correlationQueue, correlationID)
correlationQueueMu.Unlock()
// Request queued successfully
case <-ctx.Done():
// Context cancelled - send error response so response writer can advance
select {
case responseChan <- &kafkaResponse{
correlationID: correlationID,
apiKey: apiKey,
apiVersion: apiVersion,
err: ctx.Err(),
}:
default:
}
return ctx.Err()
case <-time.After(10 * time.Second):
// Channel full for too long - this shouldn't happen with proper backpressure
// Channel full for too long - send error response so response writer can advance
glog.Errorf("[%s] Failed to queue correlation=%d - channel full (10s timeout)", connectionID, correlationID)
select {
case responseChan <- &kafkaResponse{
correlationID: correlationID,
apiKey: apiKey,
apiVersion: apiVersion,
err: fmt.Errorf("request queue full"),
}:
default:
}
return fmt.Errorf("request queue full: correlation=%d", correlationID)
}
}
+7 -17
View File
@@ -28,31 +28,21 @@ func (h *Handler) handleProduce(ctx context.Context, correlationID uint32, apiVe
}
func (h *Handler) handleProduceV0V1(ctx context.Context, correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) {
// Parse Produce v0/v1 request
// Request format: client_id + acks(2) + timeout(4) + topics_array
// Parse Produce v0/v1 request body (client_id already stripped in HandleConn)
// Body format: acks(INT16) + timeout_ms(INT32) + topics(ARRAY)
if len(requestBody) < 8 { // client_id_size(2) + acks(2) + timeout(4)
if len(requestBody) < 10 { // acks(2) + timeout_ms(4) + topics_count(4)
return nil, fmt.Errorf("Produce request too short")
}
// Skip client_id
clientIDSize := binary.BigEndian.Uint16(requestBody[0:2])
offset := 0
if len(requestBody) < 2+int(clientIDSize) {
return nil, fmt.Errorf("Produce request client_id too short")
}
_ = string(requestBody[2 : 2+int(clientIDSize)]) // clientID
offset := 2 + int(clientIDSize)
if len(requestBody) < offset+10 { // acks(2) + timeout(4) + topics_count(4)
return nil, fmt.Errorf("Produce request missing data")
}
// Parse acks and timeout
_ = int16(binary.BigEndian.Uint16(requestBody[offset : offset+2])) // acks
offset += 2
_ = binary.BigEndian.Uint32(requestBody[offset : offset+4]) // timeout_ms
offset += 4
topicsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4])
offset += 4
+45 -1
View File
@@ -33,6 +33,9 @@ service SeaweedFiler {
rpc StreamRenameEntry (StreamRenameEntryRequest) returns (stream StreamRenameEntryResponse) {
}
rpc StreamMutateEntry (stream StreamMutateEntryRequest) returns (stream StreamMutateEntryResponse) {
}
rpc AssignVolume (AssignVolumeRequest) returns (AssignVolumeResponse) {
}
@@ -203,9 +206,21 @@ message CreateEntryRequest {
bool skip_check_parent_directory = 6;
}
// Structured error codes for filer entry operations.
// Values are stable — do not reorder or reuse numbers.
enum FilerError {
OK = 0;
ENTRY_NAME_TOO_LONG = 1; // name exceeds max_file_name_length
PARENT_IS_FILE = 2; // parent path component is a file, not a directory
EXISTING_IS_DIRECTORY = 3; // cannot overwrite directory with file
EXISTING_IS_FILE = 4; // cannot overwrite file with directory
ENTRY_ALREADY_EXISTS = 5; // O_EXCL and entry already exists
}
message CreateEntryResponse {
string error = 1;
string error = 1; // kept for human readability + backward compat
SubscribeMetadataResponse metadata_event = 2;
FilerError error_code = 3; // machine-readable error code
}
message UpdateEntryRequest {
@@ -523,3 +538,32 @@ message TransferLocksRequest {
}
message TransferLocksResponse {
}
//////////////////////////////////////////////////
// StreamMutateEntry: ordered bidirectional streaming for all filer mutations.
// All create/update/delete/rename operations from a single mount go through
// one stream, preserving mutation ordering and eliminating per-request
// connection overhead.
message StreamMutateEntryRequest {
uint64 request_id = 1;
oneof request {
CreateEntryRequest create_request = 2;
UpdateEntryRequest update_request = 3;
DeleteEntryRequest delete_request = 4;
StreamRenameEntryRequest rename_request = 5;
}
}
message StreamMutateEntryResponse {
uint64 request_id = 1;
bool is_last = 2; // always true except for rename, which sends multiple events
oneof response {
CreateEntryResponse create_response = 3;
UpdateEntryResponse update_response = 4;
DeleteEntryResponse delete_response = 5;
StreamRenameEntryResponse rename_response = 6;
}
string error = 7; // human-readable error message when the operation failed
int32 errno = 8; // POSIX errno (e.g. ENOENT=2, ENOTEMPTY=66) for direct FUSE status mapping
}
+565 -172
View File
@@ -73,6 +73,66 @@ func (SSEType) EnumDescriptor() ([]byte, []int) {
return file_filer_proto_rawDescGZIP(), []int{0}
}
// Structured error codes for filer entry operations.
// Values are stable — do not reorder or reuse numbers.
type FilerError int32
const (
FilerError_OK FilerError = 0
FilerError_ENTRY_NAME_TOO_LONG FilerError = 1 // name exceeds max_file_name_length
FilerError_PARENT_IS_FILE FilerError = 2 // parent path component is a file, not a directory
FilerError_EXISTING_IS_DIRECTORY FilerError = 3 // cannot overwrite directory with file
FilerError_EXISTING_IS_FILE FilerError = 4 // cannot overwrite file with directory
FilerError_ENTRY_ALREADY_EXISTS FilerError = 5 // O_EXCL and entry already exists
)
// Enum value maps for FilerError.
var (
FilerError_name = map[int32]string{
0: "OK",
1: "ENTRY_NAME_TOO_LONG",
2: "PARENT_IS_FILE",
3: "EXISTING_IS_DIRECTORY",
4: "EXISTING_IS_FILE",
5: "ENTRY_ALREADY_EXISTS",
}
FilerError_value = map[string]int32{
"OK": 0,
"ENTRY_NAME_TOO_LONG": 1,
"PARENT_IS_FILE": 2,
"EXISTING_IS_DIRECTORY": 3,
"EXISTING_IS_FILE": 4,
"ENTRY_ALREADY_EXISTS": 5,
}
)
func (x FilerError) Enum() *FilerError {
p := new(FilerError)
*p = x
return p
}
func (x FilerError) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (FilerError) Descriptor() protoreflect.EnumDescriptor {
return file_filer_proto_enumTypes[1].Descriptor()
}
func (FilerError) Type() protoreflect.EnumType {
return &file_filer_proto_enumTypes[1]
}
func (x FilerError) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use FilerError.Descriptor instead.
func (FilerError) EnumDescriptor() ([]byte, []int) {
return file_filer_proto_rawDescGZIP(), []int{1}
}
type LookupDirectoryEntryRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
@@ -1119,8 +1179,9 @@ func (x *CreateEntryRequest) GetSkipCheckParentDirectory() bool {
type CreateEntryResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // kept for human readability + backward compat
MetadataEvent *SubscribeMetadataResponse `protobuf:"bytes,2,opt,name=metadata_event,json=metadataEvent,proto3" json:"metadata_event,omitempty"`
ErrorCode FilerError `protobuf:"varint,3,opt,name=error_code,json=errorCode,proto3,enum=filer_pb.FilerError" json:"error_code,omitempty"` // machine-readable error code
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1169,6 +1230,13 @@ func (x *CreateEntryResponse) GetMetadataEvent() *SubscribeMetadataResponse {
return nil
}
func (x *CreateEntryResponse) GetErrorCode() FilerError {
if x != nil {
return x.ErrorCode
}
return FilerError_OK
}
type UpdateEntryRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
@@ -4246,6 +4314,274 @@ func (*TransferLocksResponse) Descriptor() ([]byte, []int) {
return file_filer_proto_rawDescGZIP(), []int{65}
}
type StreamMutateEntryRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId uint64 `protobuf:"varint,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
// Types that are valid to be assigned to Request:
//
// *StreamMutateEntryRequest_CreateRequest
// *StreamMutateEntryRequest_UpdateRequest
// *StreamMutateEntryRequest_DeleteRequest
// *StreamMutateEntryRequest_RenameRequest
Request isStreamMutateEntryRequest_Request `protobuf_oneof:"request"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamMutateEntryRequest) Reset() {
*x = StreamMutateEntryRequest{}
mi := &file_filer_proto_msgTypes[66]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamMutateEntryRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamMutateEntryRequest) ProtoMessage() {}
func (x *StreamMutateEntryRequest) ProtoReflect() protoreflect.Message {
mi := &file_filer_proto_msgTypes[66]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamMutateEntryRequest.ProtoReflect.Descriptor instead.
func (*StreamMutateEntryRequest) Descriptor() ([]byte, []int) {
return file_filer_proto_rawDescGZIP(), []int{66}
}
func (x *StreamMutateEntryRequest) GetRequestId() uint64 {
if x != nil {
return x.RequestId
}
return 0
}
func (x *StreamMutateEntryRequest) GetRequest() isStreamMutateEntryRequest_Request {
if x != nil {
return x.Request
}
return nil
}
func (x *StreamMutateEntryRequest) GetCreateRequest() *CreateEntryRequest {
if x != nil {
if x, ok := x.Request.(*StreamMutateEntryRequest_CreateRequest); ok {
return x.CreateRequest
}
}
return nil
}
func (x *StreamMutateEntryRequest) GetUpdateRequest() *UpdateEntryRequest {
if x != nil {
if x, ok := x.Request.(*StreamMutateEntryRequest_UpdateRequest); ok {
return x.UpdateRequest
}
}
return nil
}
func (x *StreamMutateEntryRequest) GetDeleteRequest() *DeleteEntryRequest {
if x != nil {
if x, ok := x.Request.(*StreamMutateEntryRequest_DeleteRequest); ok {
return x.DeleteRequest
}
}
return nil
}
func (x *StreamMutateEntryRequest) GetRenameRequest() *StreamRenameEntryRequest {
if x != nil {
if x, ok := x.Request.(*StreamMutateEntryRequest_RenameRequest); ok {
return x.RenameRequest
}
}
return nil
}
type isStreamMutateEntryRequest_Request interface {
isStreamMutateEntryRequest_Request()
}
type StreamMutateEntryRequest_CreateRequest struct {
CreateRequest *CreateEntryRequest `protobuf:"bytes,2,opt,name=create_request,json=createRequest,proto3,oneof"`
}
type StreamMutateEntryRequest_UpdateRequest struct {
UpdateRequest *UpdateEntryRequest `protobuf:"bytes,3,opt,name=update_request,json=updateRequest,proto3,oneof"`
}
type StreamMutateEntryRequest_DeleteRequest struct {
DeleteRequest *DeleteEntryRequest `protobuf:"bytes,4,opt,name=delete_request,json=deleteRequest,proto3,oneof"`
}
type StreamMutateEntryRequest_RenameRequest struct {
RenameRequest *StreamRenameEntryRequest `protobuf:"bytes,5,opt,name=rename_request,json=renameRequest,proto3,oneof"`
}
func (*StreamMutateEntryRequest_CreateRequest) isStreamMutateEntryRequest_Request() {}
func (*StreamMutateEntryRequest_UpdateRequest) isStreamMutateEntryRequest_Request() {}
func (*StreamMutateEntryRequest_DeleteRequest) isStreamMutateEntryRequest_Request() {}
func (*StreamMutateEntryRequest_RenameRequest) isStreamMutateEntryRequest_Request() {}
type StreamMutateEntryResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId uint64 `protobuf:"varint,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
IsLast bool `protobuf:"varint,2,opt,name=is_last,json=isLast,proto3" json:"is_last,omitempty"` // always true except for rename, which sends multiple events
// Types that are valid to be assigned to Response:
//
// *StreamMutateEntryResponse_CreateResponse
// *StreamMutateEntryResponse_UpdateResponse
// *StreamMutateEntryResponse_DeleteResponse
// *StreamMutateEntryResponse_RenameResponse
Response isStreamMutateEntryResponse_Response `protobuf_oneof:"response"`
Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` // human-readable error message when the operation failed
Errno int32 `protobuf:"varint,8,opt,name=errno,proto3" json:"errno,omitempty"` // POSIX errno (e.g. ENOENT=2, ENOTEMPTY=66) for direct FUSE status mapping
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamMutateEntryResponse) Reset() {
*x = StreamMutateEntryResponse{}
mi := &file_filer_proto_msgTypes[67]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamMutateEntryResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamMutateEntryResponse) ProtoMessage() {}
func (x *StreamMutateEntryResponse) ProtoReflect() protoreflect.Message {
mi := &file_filer_proto_msgTypes[67]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamMutateEntryResponse.ProtoReflect.Descriptor instead.
func (*StreamMutateEntryResponse) Descriptor() ([]byte, []int) {
return file_filer_proto_rawDescGZIP(), []int{67}
}
func (x *StreamMutateEntryResponse) GetRequestId() uint64 {
if x != nil {
return x.RequestId
}
return 0
}
func (x *StreamMutateEntryResponse) GetIsLast() bool {
if x != nil {
return x.IsLast
}
return false
}
func (x *StreamMutateEntryResponse) GetResponse() isStreamMutateEntryResponse_Response {
if x != nil {
return x.Response
}
return nil
}
func (x *StreamMutateEntryResponse) GetCreateResponse() *CreateEntryResponse {
if x != nil {
if x, ok := x.Response.(*StreamMutateEntryResponse_CreateResponse); ok {
return x.CreateResponse
}
}
return nil
}
func (x *StreamMutateEntryResponse) GetUpdateResponse() *UpdateEntryResponse {
if x != nil {
if x, ok := x.Response.(*StreamMutateEntryResponse_UpdateResponse); ok {
return x.UpdateResponse
}
}
return nil
}
func (x *StreamMutateEntryResponse) GetDeleteResponse() *DeleteEntryResponse {
if x != nil {
if x, ok := x.Response.(*StreamMutateEntryResponse_DeleteResponse); ok {
return x.DeleteResponse
}
}
return nil
}
func (x *StreamMutateEntryResponse) GetRenameResponse() *StreamRenameEntryResponse {
if x != nil {
if x, ok := x.Response.(*StreamMutateEntryResponse_RenameResponse); ok {
return x.RenameResponse
}
}
return nil
}
func (x *StreamMutateEntryResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
func (x *StreamMutateEntryResponse) GetErrno() int32 {
if x != nil {
return x.Errno
}
return 0
}
type isStreamMutateEntryResponse_Response interface {
isStreamMutateEntryResponse_Response()
}
type StreamMutateEntryResponse_CreateResponse struct {
CreateResponse *CreateEntryResponse `protobuf:"bytes,3,opt,name=create_response,json=createResponse,proto3,oneof"`
}
type StreamMutateEntryResponse_UpdateResponse struct {
UpdateResponse *UpdateEntryResponse `protobuf:"bytes,4,opt,name=update_response,json=updateResponse,proto3,oneof"`
}
type StreamMutateEntryResponse_DeleteResponse struct {
DeleteResponse *DeleteEntryResponse `protobuf:"bytes,5,opt,name=delete_response,json=deleteResponse,proto3,oneof"`
}
type StreamMutateEntryResponse_RenameResponse struct {
RenameResponse *StreamRenameEntryResponse `protobuf:"bytes,6,opt,name=rename_response,json=renameResponse,proto3,oneof"`
}
func (*StreamMutateEntryResponse_CreateResponse) isStreamMutateEntryResponse_Response() {}
func (*StreamMutateEntryResponse_UpdateResponse) isStreamMutateEntryResponse_Response() {}
func (*StreamMutateEntryResponse_DeleteResponse) isStreamMutateEntryResponse_Response() {}
func (*StreamMutateEntryResponse_RenameResponse) isStreamMutateEntryResponse_Response() {}
// if found, send the exact address
// if not found, send the full list of existing brokers
type LocateBrokerResponse_Resource struct {
@@ -4258,7 +4594,7 @@ type LocateBrokerResponse_Resource struct {
func (x *LocateBrokerResponse_Resource) Reset() {
*x = LocateBrokerResponse_Resource{}
mi := &file_filer_proto_msgTypes[69]
mi := &file_filer_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4270,7 +4606,7 @@ func (x *LocateBrokerResponse_Resource) String() string {
func (*LocateBrokerResponse_Resource) ProtoMessage() {}
func (x *LocateBrokerResponse_Resource) ProtoReflect() protoreflect.Message {
mi := &file_filer_proto_msgTypes[69]
mi := &file_filer_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4324,7 +4660,7 @@ type FilerConf_PathConf struct {
func (x *FilerConf_PathConf) Reset() {
*x = FilerConf_PathConf{}
mi := &file_filer_proto_msgTypes[70]
mi := &file_filer_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4336,7 +4672,7 @@ func (x *FilerConf_PathConf) String() string {
func (*FilerConf_PathConf) ProtoMessage() {}
func (x *FilerConf_PathConf) ProtoReflect() protoreflect.Message {
mi := &file_filer_proto_msgTypes[70]
mi := &file_filer_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4571,10 +4907,12 @@ const file_filer_proto_rawDesc = "" +
"\n" +
"signatures\x18\x05 \x03(\x05R\n" +
"signatures\x12=\n" +
"\x1bskip_check_parent_directory\x18\x06 \x01(\bR\x18skipCheckParentDirectory\"w\n" +
"\x1bskip_check_parent_directory\x18\x06 \x01(\bR\x18skipCheckParentDirectory\"\xac\x01\n" +
"\x13CreateEntryResponse\x12\x14\n" +
"\x05error\x18\x01 \x01(\tR\x05error\x12J\n" +
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\xd2\x02\n" +
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\x123\n" +
"\n" +
"error_code\x18\x03 \x01(\x0e2\x14.filer_pb.FilerErrorR\terrorCode\"\xd2\x02\n" +
"\x12UpdateEntryRequest\x12\x1c\n" +
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12%\n" +
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x121\n" +
@@ -4845,13 +5183,41 @@ const file_filer_proto_rawDesc = "" +
"\x05owner\x18\x04 \x01(\tR\x05owner\"<\n" +
"\x14TransferLocksRequest\x12$\n" +
"\x05locks\x18\x01 \x03(\v2\x0e.filer_pb.LockR\x05locks\"\x17\n" +
"\x15TransferLocksResponse*7\n" +
"\x15TransferLocksResponse\"\xe6\x02\n" +
"\x18StreamMutateEntryRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\x04R\trequestId\x12E\n" +
"\x0ecreate_request\x18\x02 \x01(\v2\x1c.filer_pb.CreateEntryRequestH\x00R\rcreateRequest\x12E\n" +
"\x0eupdate_request\x18\x03 \x01(\v2\x1c.filer_pb.UpdateEntryRequestH\x00R\rupdateRequest\x12E\n" +
"\x0edelete_request\x18\x04 \x01(\v2\x1c.filer_pb.DeleteEntryRequestH\x00R\rdeleteRequest\x12K\n" +
"\x0erename_request\x18\x05 \x01(\v2\".filer_pb.StreamRenameEntryRequestH\x00R\rrenameRequestB\t\n" +
"\arequest\"\xb9\x03\n" +
"\x19StreamMutateEntryResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\x04R\trequestId\x12\x17\n" +
"\ais_last\x18\x02 \x01(\bR\x06isLast\x12H\n" +
"\x0fcreate_response\x18\x03 \x01(\v2\x1d.filer_pb.CreateEntryResponseH\x00R\x0ecreateResponse\x12H\n" +
"\x0fupdate_response\x18\x04 \x01(\v2\x1d.filer_pb.UpdateEntryResponseH\x00R\x0eupdateResponse\x12H\n" +
"\x0fdelete_response\x18\x05 \x01(\v2\x1d.filer_pb.DeleteEntryResponseH\x00R\x0edeleteResponse\x12N\n" +
"\x0frename_response\x18\x06 \x01(\v2#.filer_pb.StreamRenameEntryResponseH\x00R\x0erenameResponse\x12\x14\n" +
"\x05error\x18\a \x01(\tR\x05error\x12\x14\n" +
"\x05errno\x18\b \x01(\x05R\x05errnoB\n" +
"\n" +
"\bresponse*7\n" +
"\aSSEType\x12\b\n" +
"\x04NONE\x10\x00\x12\t\n" +
"\x05SSE_C\x10\x01\x12\v\n" +
"\aSSE_KMS\x10\x02\x12\n" +
"\n" +
"\x06SSE_S3\x10\x032\xf7\x10\n" +
"\x06SSE_S3\x10\x03*\x8c\x01\n" +
"\n" +
"FilerError\x12\x06\n" +
"\x02OK\x10\x00\x12\x17\n" +
"\x13ENTRY_NAME_TOO_LONG\x10\x01\x12\x12\n" +
"\x0ePARENT_IS_FILE\x10\x02\x12\x19\n" +
"\x15EXISTING_IS_DIRECTORY\x10\x03\x12\x14\n" +
"\x10EXISTING_IS_FILE\x10\x04\x12\x18\n" +
"\x14ENTRY_ALREADY_EXISTS\x10\x052\xdb\x11\n" +
"\fSeaweedFiler\x12g\n" +
"\x14LookupDirectoryEntry\x12%.filer_pb.LookupDirectoryEntryRequest\x1a&.filer_pb.LookupDirectoryEntryResponse\"\x00\x12N\n" +
"\vListEntries\x12\x1c.filer_pb.ListEntriesRequest\x1a\x1d.filer_pb.ListEntriesResponse\"\x000\x01\x12L\n" +
@@ -4860,7 +5226,8 @@ const file_filer_proto_rawDesc = "" +
"\rAppendToEntry\x12\x1e.filer_pb.AppendToEntryRequest\x1a\x1f.filer_pb.AppendToEntryResponse\"\x00\x12L\n" +
"\vDeleteEntry\x12\x1c.filer_pb.DeleteEntryRequest\x1a\x1d.filer_pb.DeleteEntryResponse\"\x00\x12^\n" +
"\x11AtomicRenameEntry\x12\".filer_pb.AtomicRenameEntryRequest\x1a#.filer_pb.AtomicRenameEntryResponse\"\x00\x12`\n" +
"\x11StreamRenameEntry\x12\".filer_pb.StreamRenameEntryRequest\x1a#.filer_pb.StreamRenameEntryResponse\"\x000\x01\x12O\n" +
"\x11StreamRenameEntry\x12\".filer_pb.StreamRenameEntryRequest\x1a#.filer_pb.StreamRenameEntryResponse\"\x000\x01\x12b\n" +
"\x11StreamMutateEntry\x12\".filer_pb.StreamMutateEntryRequest\x1a#.filer_pb.StreamMutateEntryResponse\"\x00(\x010\x01\x12O\n" +
"\fAssignVolume\x12\x1d.filer_pb.AssignVolumeRequest\x1a\x1e.filer_pb.AssignVolumeResponse\"\x00\x12O\n" +
"\fLookupVolume\x12\x1d.filer_pb.LookupVolumeRequest\x1a\x1e.filer_pb.LookupVolumeResponse\"\x00\x12U\n" +
"\x0eCollectionList\x12\x1f.filer_pb.CollectionListRequest\x1a .filer_pb.CollectionListResponse\"\x00\x12[\n" +
@@ -4894,171 +5261,185 @@ func file_filer_proto_rawDescGZIP() []byte {
return file_filer_proto_rawDescData
}
var file_filer_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_filer_proto_msgTypes = make([]protoimpl.MessageInfo, 71)
var file_filer_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_filer_proto_msgTypes = make([]protoimpl.MessageInfo, 73)
var file_filer_proto_goTypes = []any{
(SSEType)(0), // 0: filer_pb.SSEType
(*LookupDirectoryEntryRequest)(nil), // 1: filer_pb.LookupDirectoryEntryRequest
(*LookupDirectoryEntryResponse)(nil), // 2: filer_pb.LookupDirectoryEntryResponse
(*ListEntriesRequest)(nil), // 3: filer_pb.ListEntriesRequest
(*ListEntriesResponse)(nil), // 4: filer_pb.ListEntriesResponse
(*RemoteEntry)(nil), // 5: filer_pb.RemoteEntry
(*Entry)(nil), // 6: filer_pb.Entry
(*FullEntry)(nil), // 7: filer_pb.FullEntry
(*EventNotification)(nil), // 8: filer_pb.EventNotification
(*FileChunk)(nil), // 9: filer_pb.FileChunk
(*FileChunkManifest)(nil), // 10: filer_pb.FileChunkManifest
(*FileId)(nil), // 11: filer_pb.FileId
(*FuseAttributes)(nil), // 12: filer_pb.FuseAttributes
(*CreateEntryRequest)(nil), // 13: filer_pb.CreateEntryRequest
(*CreateEntryResponse)(nil), // 14: filer_pb.CreateEntryResponse
(*UpdateEntryRequest)(nil), // 15: filer_pb.UpdateEntryRequest
(*UpdateEntryResponse)(nil), // 16: filer_pb.UpdateEntryResponse
(*AppendToEntryRequest)(nil), // 17: filer_pb.AppendToEntryRequest
(*AppendToEntryResponse)(nil), // 18: filer_pb.AppendToEntryResponse
(*DeleteEntryRequest)(nil), // 19: filer_pb.DeleteEntryRequest
(*DeleteEntryResponse)(nil), // 20: filer_pb.DeleteEntryResponse
(*AtomicRenameEntryRequest)(nil), // 21: filer_pb.AtomicRenameEntryRequest
(*AtomicRenameEntryResponse)(nil), // 22: filer_pb.AtomicRenameEntryResponse
(*StreamRenameEntryRequest)(nil), // 23: filer_pb.StreamRenameEntryRequest
(*StreamRenameEntryResponse)(nil), // 24: filer_pb.StreamRenameEntryResponse
(*AssignVolumeRequest)(nil), // 25: filer_pb.AssignVolumeRequest
(*AssignVolumeResponse)(nil), // 26: filer_pb.AssignVolumeResponse
(*LookupVolumeRequest)(nil), // 27: filer_pb.LookupVolumeRequest
(*Locations)(nil), // 28: filer_pb.Locations
(*Location)(nil), // 29: filer_pb.Location
(*LookupVolumeResponse)(nil), // 30: filer_pb.LookupVolumeResponse
(*Collection)(nil), // 31: filer_pb.Collection
(*CollectionListRequest)(nil), // 32: filer_pb.CollectionListRequest
(*CollectionListResponse)(nil), // 33: filer_pb.CollectionListResponse
(*DeleteCollectionRequest)(nil), // 34: filer_pb.DeleteCollectionRequest
(*DeleteCollectionResponse)(nil), // 35: filer_pb.DeleteCollectionResponse
(*StatisticsRequest)(nil), // 36: filer_pb.StatisticsRequest
(*StatisticsResponse)(nil), // 37: filer_pb.StatisticsResponse
(*PingRequest)(nil), // 38: filer_pb.PingRequest
(*PingResponse)(nil), // 39: filer_pb.PingResponse
(*GetFilerConfigurationRequest)(nil), // 40: filer_pb.GetFilerConfigurationRequest
(*GetFilerConfigurationResponse)(nil), // 41: filer_pb.GetFilerConfigurationResponse
(*SubscribeMetadataRequest)(nil), // 42: filer_pb.SubscribeMetadataRequest
(*SubscribeMetadataResponse)(nil), // 43: filer_pb.SubscribeMetadataResponse
(*TraverseBfsMetadataRequest)(nil), // 44: filer_pb.TraverseBfsMetadataRequest
(*TraverseBfsMetadataResponse)(nil), // 45: filer_pb.TraverseBfsMetadataResponse
(*LogEntry)(nil), // 46: filer_pb.LogEntry
(*KeepConnectedRequest)(nil), // 47: filer_pb.KeepConnectedRequest
(*KeepConnectedResponse)(nil), // 48: filer_pb.KeepConnectedResponse
(*LocateBrokerRequest)(nil), // 49: filer_pb.LocateBrokerRequest
(*LocateBrokerResponse)(nil), // 50: filer_pb.LocateBrokerResponse
(*KvGetRequest)(nil), // 51: filer_pb.KvGetRequest
(*KvGetResponse)(nil), // 52: filer_pb.KvGetResponse
(*KvPutRequest)(nil), // 53: filer_pb.KvPutRequest
(*KvPutResponse)(nil), // 54: filer_pb.KvPutResponse
(*FilerConf)(nil), // 55: filer_pb.FilerConf
(*CacheRemoteObjectToLocalClusterRequest)(nil), // 56: filer_pb.CacheRemoteObjectToLocalClusterRequest
(*CacheRemoteObjectToLocalClusterResponse)(nil), // 57: filer_pb.CacheRemoteObjectToLocalClusterResponse
(*LockRequest)(nil), // 58: filer_pb.LockRequest
(*LockResponse)(nil), // 59: filer_pb.LockResponse
(*UnlockRequest)(nil), // 60: filer_pb.UnlockRequest
(*UnlockResponse)(nil), // 61: filer_pb.UnlockResponse
(*FindLockOwnerRequest)(nil), // 62: filer_pb.FindLockOwnerRequest
(*FindLockOwnerResponse)(nil), // 63: filer_pb.FindLockOwnerResponse
(*Lock)(nil), // 64: filer_pb.Lock
(*TransferLocksRequest)(nil), // 65: filer_pb.TransferLocksRequest
(*TransferLocksResponse)(nil), // 66: filer_pb.TransferLocksResponse
nil, // 67: filer_pb.Entry.ExtendedEntry
nil, // 68: filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
nil, // 69: filer_pb.LookupVolumeResponse.LocationsMapEntry
(*LocateBrokerResponse_Resource)(nil), // 70: filer_pb.LocateBrokerResponse.Resource
(*FilerConf_PathConf)(nil), // 71: filer_pb.FilerConf.PathConf
(FilerError)(0), // 1: filer_pb.FilerError
(*LookupDirectoryEntryRequest)(nil), // 2: filer_pb.LookupDirectoryEntryRequest
(*LookupDirectoryEntryResponse)(nil), // 3: filer_pb.LookupDirectoryEntryResponse
(*ListEntriesRequest)(nil), // 4: filer_pb.ListEntriesRequest
(*ListEntriesResponse)(nil), // 5: filer_pb.ListEntriesResponse
(*RemoteEntry)(nil), // 6: filer_pb.RemoteEntry
(*Entry)(nil), // 7: filer_pb.Entry
(*FullEntry)(nil), // 8: filer_pb.FullEntry
(*EventNotification)(nil), // 9: filer_pb.EventNotification
(*FileChunk)(nil), // 10: filer_pb.FileChunk
(*FileChunkManifest)(nil), // 11: filer_pb.FileChunkManifest
(*FileId)(nil), // 12: filer_pb.FileId
(*FuseAttributes)(nil), // 13: filer_pb.FuseAttributes
(*CreateEntryRequest)(nil), // 14: filer_pb.CreateEntryRequest
(*CreateEntryResponse)(nil), // 15: filer_pb.CreateEntryResponse
(*UpdateEntryRequest)(nil), // 16: filer_pb.UpdateEntryRequest
(*UpdateEntryResponse)(nil), // 17: filer_pb.UpdateEntryResponse
(*AppendToEntryRequest)(nil), // 18: filer_pb.AppendToEntryRequest
(*AppendToEntryResponse)(nil), // 19: filer_pb.AppendToEntryResponse
(*DeleteEntryRequest)(nil), // 20: filer_pb.DeleteEntryRequest
(*DeleteEntryResponse)(nil), // 21: filer_pb.DeleteEntryResponse
(*AtomicRenameEntryRequest)(nil), // 22: filer_pb.AtomicRenameEntryRequest
(*AtomicRenameEntryResponse)(nil), // 23: filer_pb.AtomicRenameEntryResponse
(*StreamRenameEntryRequest)(nil), // 24: filer_pb.StreamRenameEntryRequest
(*StreamRenameEntryResponse)(nil), // 25: filer_pb.StreamRenameEntryResponse
(*AssignVolumeRequest)(nil), // 26: filer_pb.AssignVolumeRequest
(*AssignVolumeResponse)(nil), // 27: filer_pb.AssignVolumeResponse
(*LookupVolumeRequest)(nil), // 28: filer_pb.LookupVolumeRequest
(*Locations)(nil), // 29: filer_pb.Locations
(*Location)(nil), // 30: filer_pb.Location
(*LookupVolumeResponse)(nil), // 31: filer_pb.LookupVolumeResponse
(*Collection)(nil), // 32: filer_pb.Collection
(*CollectionListRequest)(nil), // 33: filer_pb.CollectionListRequest
(*CollectionListResponse)(nil), // 34: filer_pb.CollectionListResponse
(*DeleteCollectionRequest)(nil), // 35: filer_pb.DeleteCollectionRequest
(*DeleteCollectionResponse)(nil), // 36: filer_pb.DeleteCollectionResponse
(*StatisticsRequest)(nil), // 37: filer_pb.StatisticsRequest
(*StatisticsResponse)(nil), // 38: filer_pb.StatisticsResponse
(*PingRequest)(nil), // 39: filer_pb.PingRequest
(*PingResponse)(nil), // 40: filer_pb.PingResponse
(*GetFilerConfigurationRequest)(nil), // 41: filer_pb.GetFilerConfigurationRequest
(*GetFilerConfigurationResponse)(nil), // 42: filer_pb.GetFilerConfigurationResponse
(*SubscribeMetadataRequest)(nil), // 43: filer_pb.SubscribeMetadataRequest
(*SubscribeMetadataResponse)(nil), // 44: filer_pb.SubscribeMetadataResponse
(*TraverseBfsMetadataRequest)(nil), // 45: filer_pb.TraverseBfsMetadataRequest
(*TraverseBfsMetadataResponse)(nil), // 46: filer_pb.TraverseBfsMetadataResponse
(*LogEntry)(nil), // 47: filer_pb.LogEntry
(*KeepConnectedRequest)(nil), // 48: filer_pb.KeepConnectedRequest
(*KeepConnectedResponse)(nil), // 49: filer_pb.KeepConnectedResponse
(*LocateBrokerRequest)(nil), // 50: filer_pb.LocateBrokerRequest
(*LocateBrokerResponse)(nil), // 51: filer_pb.LocateBrokerResponse
(*KvGetRequest)(nil), // 52: filer_pb.KvGetRequest
(*KvGetResponse)(nil), // 53: filer_pb.KvGetResponse
(*KvPutRequest)(nil), // 54: filer_pb.KvPutRequest
(*KvPutResponse)(nil), // 55: filer_pb.KvPutResponse
(*FilerConf)(nil), // 56: filer_pb.FilerConf
(*CacheRemoteObjectToLocalClusterRequest)(nil), // 57: filer_pb.CacheRemoteObjectToLocalClusterRequest
(*CacheRemoteObjectToLocalClusterResponse)(nil), // 58: filer_pb.CacheRemoteObjectToLocalClusterResponse
(*LockRequest)(nil), // 59: filer_pb.LockRequest
(*LockResponse)(nil), // 60: filer_pb.LockResponse
(*UnlockRequest)(nil), // 61: filer_pb.UnlockRequest
(*UnlockResponse)(nil), // 62: filer_pb.UnlockResponse
(*FindLockOwnerRequest)(nil), // 63: filer_pb.FindLockOwnerRequest
(*FindLockOwnerResponse)(nil), // 64: filer_pb.FindLockOwnerResponse
(*Lock)(nil), // 65: filer_pb.Lock
(*TransferLocksRequest)(nil), // 66: filer_pb.TransferLocksRequest
(*TransferLocksResponse)(nil), // 67: filer_pb.TransferLocksResponse
(*StreamMutateEntryRequest)(nil), // 68: filer_pb.StreamMutateEntryRequest
(*StreamMutateEntryResponse)(nil), // 69: filer_pb.StreamMutateEntryResponse
nil, // 70: filer_pb.Entry.ExtendedEntry
nil, // 71: filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
nil, // 72: filer_pb.LookupVolumeResponse.LocationsMapEntry
(*LocateBrokerResponse_Resource)(nil), // 73: filer_pb.LocateBrokerResponse.Resource
(*FilerConf_PathConf)(nil), // 74: filer_pb.FilerConf.PathConf
}
var file_filer_proto_depIdxs = []int32{
6, // 0: filer_pb.LookupDirectoryEntryResponse.entry:type_name -> filer_pb.Entry
6, // 1: filer_pb.ListEntriesResponse.entry:type_name -> filer_pb.Entry
9, // 2: filer_pb.Entry.chunks:type_name -> filer_pb.FileChunk
12, // 3: filer_pb.Entry.attributes:type_name -> filer_pb.FuseAttributes
67, // 4: filer_pb.Entry.extended:type_name -> filer_pb.Entry.ExtendedEntry
5, // 5: filer_pb.Entry.remote_entry:type_name -> filer_pb.RemoteEntry
6, // 6: filer_pb.FullEntry.entry:type_name -> filer_pb.Entry
6, // 7: filer_pb.EventNotification.old_entry:type_name -> filer_pb.Entry
6, // 8: filer_pb.EventNotification.new_entry:type_name -> filer_pb.Entry
11, // 9: filer_pb.FileChunk.fid:type_name -> filer_pb.FileId
11, // 10: filer_pb.FileChunk.source_fid:type_name -> filer_pb.FileId
7, // 0: filer_pb.LookupDirectoryEntryResponse.entry:type_name -> filer_pb.Entry
7, // 1: filer_pb.ListEntriesResponse.entry:type_name -> filer_pb.Entry
10, // 2: filer_pb.Entry.chunks:type_name -> filer_pb.FileChunk
13, // 3: filer_pb.Entry.attributes:type_name -> filer_pb.FuseAttributes
70, // 4: filer_pb.Entry.extended:type_name -> filer_pb.Entry.ExtendedEntry
6, // 5: filer_pb.Entry.remote_entry:type_name -> filer_pb.RemoteEntry
7, // 6: filer_pb.FullEntry.entry:type_name -> filer_pb.Entry
7, // 7: filer_pb.EventNotification.old_entry:type_name -> filer_pb.Entry
7, // 8: filer_pb.EventNotification.new_entry:type_name -> filer_pb.Entry
12, // 9: filer_pb.FileChunk.fid:type_name -> filer_pb.FileId
12, // 10: filer_pb.FileChunk.source_fid:type_name -> filer_pb.FileId
0, // 11: filer_pb.FileChunk.sse_type:type_name -> filer_pb.SSEType
9, // 12: filer_pb.FileChunkManifest.chunks:type_name -> filer_pb.FileChunk
6, // 13: filer_pb.CreateEntryRequest.entry:type_name -> filer_pb.Entry
43, // 14: filer_pb.CreateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
6, // 15: filer_pb.UpdateEntryRequest.entry:type_name -> filer_pb.Entry
68, // 16: filer_pb.UpdateEntryRequest.expected_extended:type_name -> filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
43, // 17: filer_pb.UpdateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
9, // 18: filer_pb.AppendToEntryRequest.chunks:type_name -> filer_pb.FileChunk
43, // 19: filer_pb.DeleteEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
8, // 20: filer_pb.StreamRenameEntryResponse.event_notification:type_name -> filer_pb.EventNotification
29, // 21: filer_pb.AssignVolumeResponse.location:type_name -> filer_pb.Location
29, // 22: filer_pb.Locations.locations:type_name -> filer_pb.Location
69, // 23: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
31, // 24: filer_pb.CollectionListResponse.collections:type_name -> filer_pb.Collection
8, // 25: filer_pb.SubscribeMetadataResponse.event_notification:type_name -> filer_pb.EventNotification
6, // 26: filer_pb.TraverseBfsMetadataResponse.entry:type_name -> filer_pb.Entry
70, // 27: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
71, // 28: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
6, // 29: filer_pb.CacheRemoteObjectToLocalClusterResponse.entry:type_name -> filer_pb.Entry
43, // 30: filer_pb.CacheRemoteObjectToLocalClusterResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
64, // 31: filer_pb.TransferLocksRequest.locks:type_name -> filer_pb.Lock
28, // 32: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
1, // 33: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
3, // 34: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
13, // 35: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
15, // 36: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
17, // 37: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
19, // 38: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
21, // 39: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
23, // 40: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
25, // 41: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
27, // 42: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
32, // 43: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
34, // 44: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
36, // 45: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
38, // 46: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
40, // 47: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
44, // 48: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
42, // 49: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
42, // 50: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
51, // 51: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
53, // 52: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
56, // 53: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
58, // 54: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
60, // 55: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
62, // 56: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
65, // 57: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
2, // 58: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
4, // 59: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
14, // 60: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
16, // 61: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
18, // 62: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
20, // 63: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
22, // 64: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
24, // 65: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
26, // 66: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
30, // 67: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
33, // 68: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
35, // 69: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
37, // 70: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
39, // 71: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
41, // 72: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
45, // 73: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
43, // 74: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
43, // 75: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
52, // 76: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
54, // 77: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
57, // 78: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
59, // 79: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
61, // 80: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
63, // 81: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
66, // 82: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
58, // [58:83] is the sub-list for method output_type
33, // [33:58] is the sub-list for method input_type
33, // [33:33] is the sub-list for extension type_name
33, // [33:33] is the sub-list for extension extendee
0, // [0:33] is the sub-list for field type_name
10, // 12: filer_pb.FileChunkManifest.chunks:type_name -> filer_pb.FileChunk
7, // 13: filer_pb.CreateEntryRequest.entry:type_name -> filer_pb.Entry
44, // 14: filer_pb.CreateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
1, // 15: filer_pb.CreateEntryResponse.error_code:type_name -> filer_pb.FilerError
7, // 16: filer_pb.UpdateEntryRequest.entry:type_name -> filer_pb.Entry
71, // 17: filer_pb.UpdateEntryRequest.expected_extended:type_name -> filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
44, // 18: filer_pb.UpdateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
10, // 19: filer_pb.AppendToEntryRequest.chunks:type_name -> filer_pb.FileChunk
44, // 20: filer_pb.DeleteEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
9, // 21: filer_pb.StreamRenameEntryResponse.event_notification:type_name -> filer_pb.EventNotification
30, // 22: filer_pb.AssignVolumeResponse.location:type_name -> filer_pb.Location
30, // 23: filer_pb.Locations.locations:type_name -> filer_pb.Location
72, // 24: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
32, // 25: filer_pb.CollectionListResponse.collections:type_name -> filer_pb.Collection
9, // 26: filer_pb.SubscribeMetadataResponse.event_notification:type_name -> filer_pb.EventNotification
7, // 27: filer_pb.TraverseBfsMetadataResponse.entry:type_name -> filer_pb.Entry
73, // 28: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
74, // 29: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
7, // 30: filer_pb.CacheRemoteObjectToLocalClusterResponse.entry:type_name -> filer_pb.Entry
44, // 31: filer_pb.CacheRemoteObjectToLocalClusterResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
65, // 32: filer_pb.TransferLocksRequest.locks:type_name -> filer_pb.Lock
14, // 33: filer_pb.StreamMutateEntryRequest.create_request:type_name -> filer_pb.CreateEntryRequest
16, // 34: filer_pb.StreamMutateEntryRequest.update_request:type_name -> filer_pb.UpdateEntryRequest
20, // 35: filer_pb.StreamMutateEntryRequest.delete_request:type_name -> filer_pb.DeleteEntryRequest
24, // 36: filer_pb.StreamMutateEntryRequest.rename_request:type_name -> filer_pb.StreamRenameEntryRequest
15, // 37: filer_pb.StreamMutateEntryResponse.create_response:type_name -> filer_pb.CreateEntryResponse
17, // 38: filer_pb.StreamMutateEntryResponse.update_response:type_name -> filer_pb.UpdateEntryResponse
21, // 39: filer_pb.StreamMutateEntryResponse.delete_response:type_name -> filer_pb.DeleteEntryResponse
25, // 40: filer_pb.StreamMutateEntryResponse.rename_response:type_name -> filer_pb.StreamRenameEntryResponse
29, // 41: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
2, // 42: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
4, // 43: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
14, // 44: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
16, // 45: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
18, // 46: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
20, // 47: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
22, // 48: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
24, // 49: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
68, // 50: filer_pb.SeaweedFiler.StreamMutateEntry:input_type -> filer_pb.StreamMutateEntryRequest
26, // 51: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
28, // 52: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
33, // 53: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
35, // 54: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
37, // 55: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
39, // 56: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
41, // 57: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
45, // 58: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
43, // 59: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
43, // 60: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
52, // 61: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
54, // 62: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
57, // 63: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
59, // 64: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
61, // 65: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
63, // 66: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
66, // 67: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
3, // 68: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
5, // 69: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
15, // 70: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
17, // 71: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
19, // 72: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
21, // 73: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
23, // 74: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
25, // 75: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
69, // 76: filer_pb.SeaweedFiler.StreamMutateEntry:output_type -> filer_pb.StreamMutateEntryResponse
27, // 77: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
31, // 78: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
34, // 79: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
36, // 80: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
38, // 81: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
40, // 82: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
42, // 83: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
46, // 84: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
44, // 85: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
44, // 86: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
53, // 87: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
55, // 88: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
58, // 89: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
60, // 90: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
62, // 91: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
64, // 92: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
67, // 93: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
68, // [68:94] is the sub-list for method output_type
42, // [42:68] is the sub-list for method input_type
42, // [42:42] is the sub-list for extension type_name
42, // [42:42] is the sub-list for extension extendee
0, // [0:42] is the sub-list for field type_name
}
func init() { file_filer_proto_init() }
@@ -5066,13 +5447,25 @@ func file_filer_proto_init() {
if File_filer_proto != nil {
return
}
file_filer_proto_msgTypes[66].OneofWrappers = []any{
(*StreamMutateEntryRequest_CreateRequest)(nil),
(*StreamMutateEntryRequest_UpdateRequest)(nil),
(*StreamMutateEntryRequest_DeleteRequest)(nil),
(*StreamMutateEntryRequest_RenameRequest)(nil),
}
file_filer_proto_msgTypes[67].OneofWrappers = []any{
(*StreamMutateEntryResponse_CreateResponse)(nil),
(*StreamMutateEntryResponse_UpdateResponse)(nil),
(*StreamMutateEntryResponse_DeleteResponse)(nil),
(*StreamMutateEntryResponse_RenameResponse)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_filer_proto_rawDesc), len(file_filer_proto_rawDesc)),
NumEnums: 1,
NumMessages: 71,
NumEnums: 2,
NumMessages: 73,
NumExtensions: 0,
NumServices: 1,
},
+35 -3
View File
@@ -27,6 +27,7 @@ const (
SeaweedFiler_DeleteEntry_FullMethodName = "/filer_pb.SeaweedFiler/DeleteEntry"
SeaweedFiler_AtomicRenameEntry_FullMethodName = "/filer_pb.SeaweedFiler/AtomicRenameEntry"
SeaweedFiler_StreamRenameEntry_FullMethodName = "/filer_pb.SeaweedFiler/StreamRenameEntry"
SeaweedFiler_StreamMutateEntry_FullMethodName = "/filer_pb.SeaweedFiler/StreamMutateEntry"
SeaweedFiler_AssignVolume_FullMethodName = "/filer_pb.SeaweedFiler/AssignVolume"
SeaweedFiler_LookupVolume_FullMethodName = "/filer_pb.SeaweedFiler/LookupVolume"
SeaweedFiler_CollectionList_FullMethodName = "/filer_pb.SeaweedFiler/CollectionList"
@@ -58,6 +59,7 @@ type SeaweedFilerClient interface {
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
AtomicRenameEntry(ctx context.Context, in *AtomicRenameEntryRequest, opts ...grpc.CallOption) (*AtomicRenameEntryResponse, error)
StreamRenameEntry(ctx context.Context, in *StreamRenameEntryRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamRenameEntryResponse], error)
StreamMutateEntry(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[StreamMutateEntryRequest, StreamMutateEntryResponse], error)
AssignVolume(ctx context.Context, in *AssignVolumeRequest, opts ...grpc.CallOption) (*AssignVolumeResponse, error)
LookupVolume(ctx context.Context, in *LookupVolumeRequest, opts ...grpc.CallOption) (*LookupVolumeResponse, error)
CollectionList(ctx context.Context, in *CollectionListRequest, opts ...grpc.CallOption) (*CollectionListResponse, error)
@@ -184,6 +186,19 @@ func (c *seaweedFilerClient) StreamRenameEntry(ctx context.Context, in *StreamRe
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SeaweedFiler_StreamRenameEntryClient = grpc.ServerStreamingClient[StreamRenameEntryResponse]
func (c *seaweedFilerClient) StreamMutateEntry(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[StreamMutateEntryRequest, StreamMutateEntryResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[2], SeaweedFiler_StreamMutateEntry_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[StreamMutateEntryRequest, StreamMutateEntryResponse]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SeaweedFiler_StreamMutateEntryClient = grpc.BidiStreamingClient[StreamMutateEntryRequest, StreamMutateEntryResponse]
func (c *seaweedFilerClient) AssignVolume(ctx context.Context, in *AssignVolumeRequest, opts ...grpc.CallOption) (*AssignVolumeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AssignVolumeResponse)
@@ -256,7 +271,7 @@ func (c *seaweedFilerClient) GetFilerConfiguration(ctx context.Context, in *GetF
func (c *seaweedFilerClient) TraverseBfsMetadata(ctx context.Context, in *TraverseBfsMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TraverseBfsMetadataResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[2], SeaweedFiler_TraverseBfsMetadata_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[3], SeaweedFiler_TraverseBfsMetadata_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -275,7 +290,7 @@ type SeaweedFiler_TraverseBfsMetadataClient = grpc.ServerStreamingClient[Travers
func (c *seaweedFilerClient) SubscribeMetadata(ctx context.Context, in *SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SubscribeMetadataResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[3], SeaweedFiler_SubscribeMetadata_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[4], SeaweedFiler_SubscribeMetadata_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -294,7 +309,7 @@ type SeaweedFiler_SubscribeMetadataClient = grpc.ServerStreamingClient[Subscribe
func (c *seaweedFilerClient) SubscribeLocalMetadata(ctx context.Context, in *SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SubscribeMetadataResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[4], SeaweedFiler_SubscribeLocalMetadata_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &SeaweedFiler_ServiceDesc.Streams[5], SeaweedFiler_SubscribeLocalMetadata_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -393,6 +408,7 @@ type SeaweedFilerServer interface {
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
AtomicRenameEntry(context.Context, *AtomicRenameEntryRequest) (*AtomicRenameEntryResponse, error)
StreamRenameEntry(*StreamRenameEntryRequest, grpc.ServerStreamingServer[StreamRenameEntryResponse]) error
StreamMutateEntry(grpc.BidiStreamingServer[StreamMutateEntryRequest, StreamMutateEntryResponse]) error
AssignVolume(context.Context, *AssignVolumeRequest) (*AssignVolumeResponse, error)
LookupVolume(context.Context, *LookupVolumeRequest) (*LookupVolumeResponse, error)
CollectionList(context.Context, *CollectionListRequest) (*CollectionListResponse, error)
@@ -445,6 +461,9 @@ func (UnimplementedSeaweedFilerServer) AtomicRenameEntry(context.Context, *Atomi
func (UnimplementedSeaweedFilerServer) StreamRenameEntry(*StreamRenameEntryRequest, grpc.ServerStreamingServer[StreamRenameEntryResponse]) error {
return status.Errorf(codes.Unimplemented, "method StreamRenameEntry not implemented")
}
func (UnimplementedSeaweedFilerServer) StreamMutateEntry(grpc.BidiStreamingServer[StreamMutateEntryRequest, StreamMutateEntryResponse]) error {
return status.Errorf(codes.Unimplemented, "method StreamMutateEntry not implemented")
}
func (UnimplementedSeaweedFilerServer) AssignVolume(context.Context, *AssignVolumeRequest) (*AssignVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AssignVolume not implemented")
}
@@ -647,6 +666,13 @@ func _SeaweedFiler_StreamRenameEntry_Handler(srv interface{}, stream grpc.Server
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SeaweedFiler_StreamRenameEntryServer = grpc.ServerStreamingServer[StreamRenameEntryResponse]
func _SeaweedFiler_StreamMutateEntry_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(SeaweedFilerServer).StreamMutateEntry(&grpc.GenericServerStream[StreamMutateEntryRequest, StreamMutateEntryResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SeaweedFiler_StreamMutateEntryServer = grpc.BidiStreamingServer[StreamMutateEntryRequest, StreamMutateEntryResponse]
func _SeaweedFiler_AssignVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AssignVolumeRequest)
if err := dec(in); err != nil {
@@ -1031,6 +1057,12 @@ var SeaweedFiler_ServiceDesc = grpc.ServiceDesc{
Handler: _SeaweedFiler_StreamRenameEntry_Handler,
ServerStreams: true,
},
{
StreamName: "StreamMutateEntry",
Handler: _SeaweedFiler_StreamMutateEntry_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "TraverseBfsMetadata",
Handler: _SeaweedFiler_TraverseBfsMetadata_Handler,
+38
View File
@@ -145,6 +145,13 @@ func CreateEntryWithResponse(ctx context.Context, client SeaweedFilerClient, req
glog.V(1).InfofCtx(ctx, "create entry %s/%s %v: %v", request.Directory, request.Entry.Name, request.OExcl, err)
return nil, fmt.Errorf("CreateEntry: %w", err)
}
if resp.ErrorCode != FilerError_OK {
glog.V(1).InfofCtx(ctx, "create entry %s/%s %v: %v (code %v)", request.Directory, request.Entry.Name, request.OExcl, resp.Error, resp.ErrorCode)
if sentinel := FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
return nil, fmt.Errorf("CreateEntry %s/%s: %w", request.Directory, request.Entry.Name, sentinel)
}
return nil, fmt.Errorf("CreateEntry: %w", errors.New(resp.Error))
}
if resp.Error != "" {
glog.V(1).InfofCtx(ctx, "create entry %s/%s %v: %v", request.Directory, request.Entry.Name, request.OExcl, resp.Error)
return nil, fmt.Errorf("CreateEntry: %w", errors.New(resp.Error))
@@ -183,6 +190,37 @@ func LookupEntry(ctx context.Context, client SeaweedFilerClient, request *Lookup
var ErrNotFound = errors.New("filer: no entry is found in filer store")
// Sentinel errors for filer entry operations.
// These are set by the filer and reconstructed from FilerError codes after
// crossing the gRPC boundary, so consumers can use errors.Is() instead of
// parsing error strings.
var (
ErrEntryNameTooLong = errors.New("entry name too long")
ErrParentIsFile = errors.New("parent path is a file")
ErrExistingIsDirectory = errors.New("existing entry is a directory")
ErrExistingIsFile = errors.New("existing entry is a file")
ErrEntryAlreadyExists = errors.New("entry already exists")
)
// FilerErrorToSentinel maps a proto FilerError code to its sentinel error.
// Returns nil for OK or unknown codes.
func FilerErrorToSentinel(code FilerError) error {
switch code {
case FilerError_ENTRY_NAME_TOO_LONG:
return ErrEntryNameTooLong
case FilerError_PARENT_IS_FILE:
return ErrParentIsFile
case FilerError_EXISTING_IS_DIRECTORY:
return ErrExistingIsDirectory
case FilerError_EXISTING_IS_FILE:
return ErrExistingIsFile
case FilerError_ENTRY_ALREADY_EXISTS:
return ErrEntryAlreadyExists
default:
return nil
}
}
func IsEmpty(event *SubscribeMetadataResponse) bool {
return event.EventNotification.NewEntry == nil && event.EventNotification.OldEntry == nil
}
+1
View File
@@ -656,6 +656,7 @@ message ScrubVolumeRequest {
VolumeScrubMode mode = 1;
// optional list of volume IDs to scrub. if empty, all volumes for the server are scrubbed.
repeated uint32 volume_ids = 2;
bool mark_broken_volumes_readonly = 3;
}
message ScrubVolumeResponse {
uint64 total_volumes = 1;
+20 -10
View File
@@ -1,18 +1,19 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v6.33.4
// protoc-gen-go v1.36.6
// protoc v6.33.4
// source: volume_server.proto
package volume_server_pb
import (
remote_pb "github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
remote_pb "github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
@@ -5578,9 +5579,10 @@ type ScrubVolumeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Mode VolumeScrubMode `protobuf:"varint,1,opt,name=mode,proto3,enum=volume_server_pb.VolumeScrubMode" json:"mode,omitempty"`
// optional list of volume IDs to scrub. if empty, all volumes for the server are scrubbed.
VolumeIds []uint32 `protobuf:"varint,2,rep,packed,name=volume_ids,json=volumeIds,proto3" json:"volume_ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
VolumeIds []uint32 `protobuf:"varint,2,rep,packed,name=volume_ids,json=volumeIds,proto3" json:"volume_ids,omitempty"`
MarkBrokenVolumesReadonly bool `protobuf:"varint,3,opt,name=mark_broken_volumes_readonly,json=markBrokenVolumesReadonly,proto3" json:"mark_broken_volumes_readonly,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ScrubVolumeRequest) Reset() {
@@ -5627,6 +5629,13 @@ func (x *ScrubVolumeRequest) GetVolumeIds() []uint32 {
return nil
}
func (x *ScrubVolumeRequest) GetMarkBrokenVolumesReadonly() bool {
if x != nil {
return x.MarkBrokenVolumesReadonly
}
return false
}
type ScrubVolumeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TotalVolumes uint64 `protobuf:"varint,1,opt,name=total_volumes,json=totalVolumes,proto3" json:"total_volumes,omitempty"`
@@ -7146,11 +7155,12 @@ const file_volume_server_proto_rawDesc = "" +
"public_url\x18\x02 \x01(\tR\tpublicUrl\x12\x1b\n" +
"\tgrpc_port\x18\x03 \x01(\x05R\bgrpcPort\"2\n" +
"\x1bFetchAndWriteNeedleResponse\x12\x13\n" +
"\x05e_tag\x18\x01 \x01(\tR\x04eTag\"j\n" +
"\x05e_tag\x18\x01 \x01(\tR\x04eTag\"\xab\x01\n" +
"\x12ScrubVolumeRequest\x125\n" +
"\x04mode\x18\x01 \x01(\x0e2!.volume_server_pb.VolumeScrubModeR\x04mode\x12\x1d\n" +
"\n" +
"volume_ids\x18\x02 \x03(\rR\tvolumeIds\"\xa1\x01\n" +
"volume_ids\x18\x02 \x03(\rR\tvolumeIds\x12?\n" +
"\x1cmark_broken_volumes_readonly\x18\x03 \x01(\bR\x19markBrokenVolumesReadonly\"\xa1\x01\n" +
"\x13ScrubVolumeResponse\x12#\n" +
"\rtotal_volumes\x18\x01 \x01(\x04R\ftotalVolumes\x12\x1f\n" +
"\vtotal_files\x18\x02 \x01(\x04R\n" +
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.4
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.21.12
// source: volume_server.proto
package volume_server_pb
@@ -781,148 +781,148 @@ type VolumeServerServer interface {
type UnimplementedVolumeServerServer struct{}
func (UnimplementedVolumeServerServer) BatchDelete(context.Context, *BatchDeleteRequest) (*BatchDeleteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchDelete not implemented")
return nil, status.Error(codes.Unimplemented, "method BatchDelete not implemented")
}
func (UnimplementedVolumeServerServer) VacuumVolumeCheck(context.Context, *VacuumVolumeCheckRequest) (*VacuumVolumeCheckResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VacuumVolumeCheck not implemented")
return nil, status.Error(codes.Unimplemented, "method VacuumVolumeCheck not implemented")
}
func (UnimplementedVolumeServerServer) VacuumVolumeCompact(*VacuumVolumeCompactRequest, grpc.ServerStreamingServer[VacuumVolumeCompactResponse]) error {
return status.Errorf(codes.Unimplemented, "method VacuumVolumeCompact not implemented")
return status.Error(codes.Unimplemented, "method VacuumVolumeCompact not implemented")
}
func (UnimplementedVolumeServerServer) VacuumVolumeCommit(context.Context, *VacuumVolumeCommitRequest) (*VacuumVolumeCommitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VacuumVolumeCommit not implemented")
return nil, status.Error(codes.Unimplemented, "method VacuumVolumeCommit not implemented")
}
func (UnimplementedVolumeServerServer) VacuumVolumeCleanup(context.Context, *VacuumVolumeCleanupRequest) (*VacuumVolumeCleanupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VacuumVolumeCleanup not implemented")
return nil, status.Error(codes.Unimplemented, "method VacuumVolumeCleanup not implemented")
}
func (UnimplementedVolumeServerServer) DeleteCollection(context.Context, *DeleteCollectionRequest) (*DeleteCollectionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteCollection not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteCollection not implemented")
}
func (UnimplementedVolumeServerServer) AllocateVolume(context.Context, *AllocateVolumeRequest) (*AllocateVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AllocateVolume not implemented")
return nil, status.Error(codes.Unimplemented, "method AllocateVolume not implemented")
}
func (UnimplementedVolumeServerServer) VolumeSyncStatus(context.Context, *VolumeSyncStatusRequest) (*VolumeSyncStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeSyncStatus not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeSyncStatus not implemented")
}
func (UnimplementedVolumeServerServer) VolumeIncrementalCopy(*VolumeIncrementalCopyRequest, grpc.ServerStreamingServer[VolumeIncrementalCopyResponse]) error {
return status.Errorf(codes.Unimplemented, "method VolumeIncrementalCopy not implemented")
return status.Error(codes.Unimplemented, "method VolumeIncrementalCopy not implemented")
}
func (UnimplementedVolumeServerServer) VolumeMount(context.Context, *VolumeMountRequest) (*VolumeMountResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeMount not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeMount not implemented")
}
func (UnimplementedVolumeServerServer) VolumeUnmount(context.Context, *VolumeUnmountRequest) (*VolumeUnmountResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeUnmount not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeUnmount not implemented")
}
func (UnimplementedVolumeServerServer) VolumeDelete(context.Context, *VolumeDeleteRequest) (*VolumeDeleteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeDelete not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeDelete not implemented")
}
func (UnimplementedVolumeServerServer) VolumeMarkReadonly(context.Context, *VolumeMarkReadonlyRequest) (*VolumeMarkReadonlyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeMarkReadonly not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeMarkReadonly not implemented")
}
func (UnimplementedVolumeServerServer) VolumeMarkWritable(context.Context, *VolumeMarkWritableRequest) (*VolumeMarkWritableResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeMarkWritable not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeMarkWritable not implemented")
}
func (UnimplementedVolumeServerServer) VolumeConfigure(context.Context, *VolumeConfigureRequest) (*VolumeConfigureResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeConfigure not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeConfigure not implemented")
}
func (UnimplementedVolumeServerServer) VolumeStatus(context.Context, *VolumeStatusRequest) (*VolumeStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeStatus not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeStatus not implemented")
}
func (UnimplementedVolumeServerServer) GetState(context.Context, *GetStateRequest) (*GetStateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
return nil, status.Error(codes.Unimplemented, "method GetState not implemented")
}
func (UnimplementedVolumeServerServer) SetState(context.Context, *SetStateRequest) (*SetStateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetState not implemented")
return nil, status.Error(codes.Unimplemented, "method SetState not implemented")
}
func (UnimplementedVolumeServerServer) VolumeCopy(*VolumeCopyRequest, grpc.ServerStreamingServer[VolumeCopyResponse]) error {
return status.Errorf(codes.Unimplemented, "method VolumeCopy not implemented")
return status.Error(codes.Unimplemented, "method VolumeCopy not implemented")
}
func (UnimplementedVolumeServerServer) ReadVolumeFileStatus(context.Context, *ReadVolumeFileStatusRequest) (*ReadVolumeFileStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReadVolumeFileStatus not implemented")
return nil, status.Error(codes.Unimplemented, "method ReadVolumeFileStatus not implemented")
}
func (UnimplementedVolumeServerServer) CopyFile(*CopyFileRequest, grpc.ServerStreamingServer[CopyFileResponse]) error {
return status.Errorf(codes.Unimplemented, "method CopyFile not implemented")
return status.Error(codes.Unimplemented, "method CopyFile not implemented")
}
func (UnimplementedVolumeServerServer) ReceiveFile(grpc.ClientStreamingServer[ReceiveFileRequest, ReceiveFileResponse]) error {
return status.Errorf(codes.Unimplemented, "method ReceiveFile not implemented")
return status.Error(codes.Unimplemented, "method ReceiveFile not implemented")
}
func (UnimplementedVolumeServerServer) ReadNeedleBlob(context.Context, *ReadNeedleBlobRequest) (*ReadNeedleBlobResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReadNeedleBlob not implemented")
return nil, status.Error(codes.Unimplemented, "method ReadNeedleBlob not implemented")
}
func (UnimplementedVolumeServerServer) ReadNeedleMeta(context.Context, *ReadNeedleMetaRequest) (*ReadNeedleMetaResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReadNeedleMeta not implemented")
return nil, status.Error(codes.Unimplemented, "method ReadNeedleMeta not implemented")
}
func (UnimplementedVolumeServerServer) WriteNeedleBlob(context.Context, *WriteNeedleBlobRequest) (*WriteNeedleBlobResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method WriteNeedleBlob not implemented")
return nil, status.Error(codes.Unimplemented, "method WriteNeedleBlob not implemented")
}
func (UnimplementedVolumeServerServer) ReadAllNeedles(*ReadAllNeedlesRequest, grpc.ServerStreamingServer[ReadAllNeedlesResponse]) error {
return status.Errorf(codes.Unimplemented, "method ReadAllNeedles not implemented")
return status.Error(codes.Unimplemented, "method ReadAllNeedles not implemented")
}
func (UnimplementedVolumeServerServer) VolumeTailSender(*VolumeTailSenderRequest, grpc.ServerStreamingServer[VolumeTailSenderResponse]) error {
return status.Errorf(codes.Unimplemented, "method VolumeTailSender not implemented")
return status.Error(codes.Unimplemented, "method VolumeTailSender not implemented")
}
func (UnimplementedVolumeServerServer) VolumeTailReceiver(context.Context, *VolumeTailReceiverRequest) (*VolumeTailReceiverResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeTailReceiver not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeTailReceiver not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsGenerate(context.Context, *VolumeEcShardsGenerateRequest) (*VolumeEcShardsGenerateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsGenerate not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsGenerate not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsRebuild(context.Context, *VolumeEcShardsRebuildRequest) (*VolumeEcShardsRebuildResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsRebuild not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsRebuild not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsCopy(context.Context, *VolumeEcShardsCopyRequest) (*VolumeEcShardsCopyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsCopy not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsCopy not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsDelete(context.Context, *VolumeEcShardsDeleteRequest) (*VolumeEcShardsDeleteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsDelete not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsDelete not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsMount(context.Context, *VolumeEcShardsMountRequest) (*VolumeEcShardsMountResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsMount not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsMount not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsUnmount(context.Context, *VolumeEcShardsUnmountRequest) (*VolumeEcShardsUnmountResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsUnmount not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsUnmount not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardRead(*VolumeEcShardReadRequest, grpc.ServerStreamingServer[VolumeEcShardReadResponse]) error {
return status.Errorf(codes.Unimplemented, "method VolumeEcShardRead not implemented")
return status.Error(codes.Unimplemented, "method VolumeEcShardRead not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcBlobDelete(context.Context, *VolumeEcBlobDeleteRequest) (*VolumeEcBlobDeleteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcBlobDelete not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcBlobDelete not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsToVolume(context.Context, *VolumeEcShardsToVolumeRequest) (*VolumeEcShardsToVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsToVolume not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsToVolume not implemented")
}
func (UnimplementedVolumeServerServer) VolumeEcShardsInfo(context.Context, *VolumeEcShardsInfoRequest) (*VolumeEcShardsInfoResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeEcShardsInfo not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeEcShardsInfo not implemented")
}
func (UnimplementedVolumeServerServer) VolumeTierMoveDatToRemote(*VolumeTierMoveDatToRemoteRequest, grpc.ServerStreamingServer[VolumeTierMoveDatToRemoteResponse]) error {
return status.Errorf(codes.Unimplemented, "method VolumeTierMoveDatToRemote not implemented")
return status.Error(codes.Unimplemented, "method VolumeTierMoveDatToRemote not implemented")
}
func (UnimplementedVolumeServerServer) VolumeTierMoveDatFromRemote(*VolumeTierMoveDatFromRemoteRequest, grpc.ServerStreamingServer[VolumeTierMoveDatFromRemoteResponse]) error {
return status.Errorf(codes.Unimplemented, "method VolumeTierMoveDatFromRemote not implemented")
return status.Error(codes.Unimplemented, "method VolumeTierMoveDatFromRemote not implemented")
}
func (UnimplementedVolumeServerServer) VolumeServerStatus(context.Context, *VolumeServerStatusRequest) (*VolumeServerStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeServerStatus not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeServerStatus not implemented")
}
func (UnimplementedVolumeServerServer) VolumeServerLeave(context.Context, *VolumeServerLeaveRequest) (*VolumeServerLeaveResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeServerLeave not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeServerLeave not implemented")
}
func (UnimplementedVolumeServerServer) FetchAndWriteNeedle(context.Context, *FetchAndWriteNeedleRequest) (*FetchAndWriteNeedleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FetchAndWriteNeedle not implemented")
return nil, status.Error(codes.Unimplemented, "method FetchAndWriteNeedle not implemented")
}
func (UnimplementedVolumeServerServer) ScrubVolume(context.Context, *ScrubVolumeRequest) (*ScrubVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ScrubVolume not implemented")
return nil, status.Error(codes.Unimplemented, "method ScrubVolume not implemented")
}
func (UnimplementedVolumeServerServer) ScrubEcVolume(context.Context, *ScrubEcVolumeRequest) (*ScrubEcVolumeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ScrubEcVolume not implemented")
return nil, status.Error(codes.Unimplemented, "method ScrubEcVolume not implemented")
}
func (UnimplementedVolumeServerServer) Query(*QueryRequest, grpc.ServerStreamingServer[QueriedStripe]) error {
return status.Errorf(codes.Unimplemented, "method Query not implemented")
return status.Error(codes.Unimplemented, "method Query not implemented")
}
func (UnimplementedVolumeServerServer) VolumeNeedleStatus(context.Context, *VolumeNeedleStatusRequest) (*VolumeNeedleStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VolumeNeedleStatus not implemented")
return nil, status.Error(codes.Unimplemented, "method VolumeNeedleStatus not implemented")
}
func (UnimplementedVolumeServerServer) Ping(context.Context, *PingRequest) (*PingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
return nil, status.Error(codes.Unimplemented, "method Ping not implemented")
}
func (UnimplementedVolumeServerServer) mustEmbedUnimplementedVolumeServerServer() {}
func (UnimplementedVolumeServerServer) testEmbeddedByValue() {}
@@ -935,7 +935,7 @@ type UnsafeVolumeServerServer interface {
}
func RegisterVolumeServerServer(s grpc.ServiceRegistrar, srv VolumeServerServer) {
// If the following call pancis, it indicates UnimplementedVolumeServerServer was
// If the following call panics, it indicates UnimplementedVolumeServerServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
+7 -9
View File
@@ -1,7 +1,6 @@
package s3api
import (
"errors"
"strings"
"time"
@@ -224,14 +223,13 @@ func (s3a *S3ApiServer) updateBucketConfigCacheFromEntry(entry *filer_pb.Entry)
// Sync bucket policy to the policy engine for evaluation
s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy)
// Load CORS configuration from bucket directory content
if corsConfig, err := s3a.loadCORSFromBucketContent(bucket); err != nil {
if !errors.Is(err, filer_pb.ErrNotFound) {
glog.Errorf("updateBucketConfigCacheFromEntry: failed to load CORS configuration for bucket %s: %v", bucket, err)
}
} else {
config.CORS = corsConfig
glog.V(2).Infof("updateBucketConfigCacheFromEntry: loaded CORS config for bucket %s", bucket)
// Parse CORS configuration directly from the subscription entry's Content field.
// This avoids a separate RPC call that could return stale data when racing with
// concurrent metadata updates (e.g., PutBucketCors clearing the cache while this
// handler is still processing an older event).
config.CORS = parseCORSFromEntryContent(entry.Content)
if config.CORS != nil {
glog.V(2).Infof("updateBucketConfigCacheFromEntry: parsed CORS config for bucket %s from entry content", bucket)
}
// Update timestamp
+89 -6
View File
@@ -11,6 +11,8 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s3a *S3ApiServer) mkdir(parentDirectoryPath string, dirName string, fn func(entry *filer_pb.Entry)) error {
@@ -47,16 +49,32 @@ func (s3a *S3ApiServer) rm(parentDirectoryPath, entryName string, isDeleteData,
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
err := doDeleteEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
if err != nil {
return err
}
return nil
return doDeleteEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
})
}
func (s3a *S3ApiServer) rmObject(parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return deleteObjectEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
})
}
func deleteObjectEntry(client filer_pb.SeaweedFilerClient, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
err := doDeleteEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
if err == nil {
return nil
}
if !strings.Contains(err.Error(), filer.MsgFailDelNonEmptyFolder) {
return err
}
return demoteDirectoryMarkerToImplicitDirectory(client, parentDirectoryPath, entryName)
}
func doDeleteEntry(client filer_pb.SeaweedFilerClient, parentDirectoryPath string, entryName string, isDeleteData bool, isRecursive bool) error {
request := &filer_pb.DeleteEntryRequest{
Directory: parentDirectoryPath,
@@ -78,6 +96,71 @@ func doDeleteEntry(client filer_pb.SeaweedFilerClient, parentDirectoryPath strin
return nil
}
func demoteDirectoryMarkerToImplicitDirectory(client filer_pb.SeaweedFilerClient, parentDirectoryPath, entryName string) error {
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: parentDirectoryPath,
Name: entryName,
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return fmt.Errorf("lookup entry %s/%s: %w", parentDirectoryPath, entryName, err)
}
if resp.Entry == nil || !resp.Entry.IsDirectory {
return nil
}
if !resp.Entry.IsDirectoryKeyObject() {
return nil
}
clearDirectoryMarkerMetadata(resp.Entry)
if err := filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
Directory: parentDirectoryPath,
Entry: resp.Entry,
}); err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound {
return nil
}
return fmt.Errorf("update entry %s/%s: %w", parentDirectoryPath, entryName, err)
}
return nil
}
func clearDirectoryMarkerMetadata(entry *filer_pb.Entry) {
if entry == nil {
return
}
if entry.Attributes == nil {
entry.Attributes = &filer_pb.FuseAttributes{}
}
entry.Attributes.Mime = ""
entry.Attributes.Md5 = nil
entry.Attributes.FileSize = 0
entry.Content = nil
entry.Chunks = nil
if len(entry.Extended) == 0 {
return
}
filtered := make(map[string][]byte)
for k, v := range entry.Extended {
lowerKey := strings.ToLower(k)
if strings.HasPrefix(lowerKey, "xattr-") || strings.HasPrefix(lowerKey, s3_constants.SeaweedFSInternalPrefix) {
filtered[k] = v
}
}
if len(filtered) == 0 {
entry.Extended = nil
return
}
entry.Extended = filtered
}
func (s3a *S3ApiServer) exists(parentDirectoryPath string, entryName string, isDirectory bool) (exists bool, err error) {
return filer_pb.Exists(context.Background(), s3a, parentDirectoryPath, entryName, isDirectory)
+152
View File
@@ -0,0 +1,152 @@
package s3api
import (
"context"
"errors"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
grpc "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type deleteObjectEntryTestClient struct {
filer_pb.SeaweedFilerClient
deleteResp *filer_pb.DeleteEntryResponse
deleteErr error
lookupResp *filer_pb.LookupDirectoryEntryResponse
lookupErr error
updateErr error
deleteReq *filer_pb.DeleteEntryRequest
lookupReq *filer_pb.LookupDirectoryEntryRequest
updateReq *filer_pb.UpdateEntryRequest
}
func (c *deleteObjectEntryTestClient) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
c.deleteReq = req
if c.deleteResp == nil {
return &filer_pb.DeleteEntryResponse{}, c.deleteErr
}
return c.deleteResp, c.deleteErr
}
func (c *deleteObjectEntryTestClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
c.lookupReq = req
if c.lookupResp == nil {
return &filer_pb.LookupDirectoryEntryResponse{}, c.lookupErr
}
return c.lookupResp, c.lookupErr
}
func (c *deleteObjectEntryTestClient) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
c.updateReq = req
return &filer_pb.UpdateEntryResponse{}, c.updateErr
}
func TestDeleteObjectEntryDemotesNonEmptyDirectoryMarker(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
Mime: "application/octet-stream",
Md5: []byte{1, 2, 3, 4},
FileSize: 4,
},
Content: []byte("test"),
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte("etag"),
s3_constants.ExtAmzOwnerKey: []byte("owner"),
s3_constants.AmzUserMetaPrefix + "Color": []byte("blue"),
s3_constants.AmzObjectTaggingPrefix + "k": []byte("v"),
"xattr-keep": []byte("keep-me"),
"x-seaweedfs-internal": []byte("keep-me-too"),
},
},
},
}
err := deleteObjectEntry(client, "/buckets/test", "photos", true, false)
require.NoError(t, err)
require.NotNil(t, client.lookupReq)
require.NotNil(t, client.updateReq)
updated := client.updateReq.Entry
require.NotNil(t, updated)
assert.False(t, updated.IsDirectoryKeyObject())
assert.Equal(t, "", updated.Attributes.Mime)
assert.Empty(t, updated.Attributes.Md5)
assert.Zero(t, updated.Attributes.FileSize)
assert.Nil(t, updated.Content)
assert.Nil(t, updated.Chunks)
assert.Equal(t, map[string][]byte{
"xattr-keep": []byte("keep-me"),
"x-seaweedfs-internal": []byte("keep-me-too"),
}, updated.Extended)
}
func TestDeleteObjectEntryTreatsImplicitDirectoryAsSuccessfulNoop(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
},
},
}
err := deleteObjectEntry(client, "/buckets/test", "photos", true, false)
require.NoError(t, err)
require.NotNil(t, client.lookupReq)
assert.Nil(t, client.updateReq)
}
func TestDeleteObjectEntryIgnoresConcurrentUpdateNotFound(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteResp: &filer_pb.DeleteEntryResponse{
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
},
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: "photos",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
Mime: "application/octet-stream",
},
},
},
updateErr: status.Error(codes.NotFound, "already removed"),
}
err := deleteObjectEntry(client, "/buckets/test", "photos", true, false)
require.NoError(t, err)
require.NotNil(t, client.lookupReq)
require.NotNil(t, client.updateReq)
}
func TestDeleteObjectEntryPropagatesNonDirectoryDeleteErrors(t *testing.T) {
client := &deleteObjectEntryTestClient{
deleteErr: errors.New("boom"),
}
err := deleteObjectEntry(client, "/buckets/test", "photos", true, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "boom")
assert.Nil(t, client.lookupReq)
assert.Nil(t, client.updateReq)
}
+6 -3
View File
@@ -51,7 +51,7 @@ func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, me
if createErr != nil {
return fmt.Errorf("failed to create %s: %w", errorContext, createErr)
}
if resp.Error != "" && !strings.Contains(resp.Error, "exist") {
if resp.ErrorCode != filer_pb.FilerError_OK && resp.ErrorCode != filer_pb.FilerError_ENTRY_ALREADY_EXISTS {
return fmt.Errorf("failed to create %s: %s", errorContext, resp.Error)
}
return nil
@@ -104,8 +104,11 @@ func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, me
if err != nil {
return fmt.Errorf("failed to write metadata file: %w", err)
}
if resp.Error != "" {
return fmt.Errorf("failed to write metadata file: %s", resp.Error)
if resp.ErrorCode != filer_pb.FilerError_OK {
if sentinel := filer_pb.FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
return fmt.Errorf("failed to write metadata file: %w", sentinel)
}
return fmt.Errorf("failed to write metadata file: code=%v %s", resp.ErrorCode, resp.Error)
}
return nil
})
+6 -3
View File
@@ -198,7 +198,7 @@ func (s *Server) writeStageCreateMarker(ctx context.Context, bucketName string,
if createErr != nil {
return fmt.Errorf("failed to create %s: %w", errorContext, createErr)
}
if resp.Error != "" && !strings.Contains(resp.Error, "exist") {
if resp.ErrorCode != filer_pb.FilerError_OK && resp.ErrorCode != filer_pb.FilerError_ENTRY_ALREADY_EXISTS {
return fmt.Errorf("failed to create %s: %s", errorContext, resp.Error)
}
return nil
@@ -236,8 +236,11 @@ func (s *Server) writeStageCreateMarker(ctx context.Context, bucketName string,
if createErr != nil {
return createErr
}
if resp.Error != "" {
return errors.New(resp.Error)
if resp.ErrorCode != filer_pb.FilerError_OK {
if sentinel := filer_pb.FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
return fmt.Errorf("create stage marker: %w", sentinel)
}
return fmt.Errorf("create stage marker: code=%v %s", resp.ErrorCode, resp.Error)
}
return nil
})
+4 -3
View File
@@ -427,10 +427,11 @@ func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
// HTTP method
values["s3:RequestMethod"] = []string{r.Method}
// Extract custom headers
// Extract custom headers with s3: prefix for AWS-compatible condition keys
for key, headerValues := range r.Header {
if strings.HasPrefix(strings.ToLower(key), "x-amz-") {
values[strings.ToLower(key)] = headerValues
lowerKey := strings.ToLower(key)
if strings.HasPrefix(lowerKey, "x-amz-") {
values["s3:"+lowerKey] = headerValues
}
}
+2 -2
View File
@@ -444,8 +444,8 @@ func TestExtractConditionValuesFromRequest(t *testing.T) {
t.Errorf("Expected RequestMethod to be GET, got %v", values["s3:RequestMethod"])
}
if len(values["x-amz-copy-source"]) != 1 || values["x-amz-copy-source"][0] != "source-bucket/source-object" {
t.Errorf("Expected X-Amz-Copy-Source header to be extracted, got %v", values["x-amz-copy-source"])
if len(values["s3:x-amz-copy-source"]) != 1 || values["s3:x-amz-copy-source"][0] != "source-bucket/source-object" {
t.Errorf("Expected X-Amz-Copy-Source header to be extracted with s3: prefix, got %v", values["s3:x-amz-copy-source"])
}
// Check that aws:CurrentTime is properly set
+5
View File
@@ -29,6 +29,11 @@ const (
S3Namespace = "http://s3.amazonaws.com/doc/2006-03-01/"
)
// S3 object key limits
const (
MaxS3ObjectKeyLength = 1024
)
// Standard S3 HTTP request constants
const (
// S3 storage class
+2 -3
View File
@@ -15,7 +15,6 @@ import (
mathrand "math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -359,8 +358,8 @@ func (km *SSES3KeyManager) generateAndSaveSuperKeyToFiler() error {
// Set appropriate permissions for the directory
entry.Attributes.FileMode = uint32(0700 | os.ModeDir)
}); err != nil {
// Only ignore "file exists" errors.
if !strings.Contains(err.Error(), "file exists") {
// Only ignore "already exists" errors.
if !errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
return fmt.Errorf("failed to create KEK directory %s: %w", SSES3KEKDirectory, err)
}
glog.V(3).Infof("Parent directory %s already exists, continuing.", SSES3KEKDirectory)
+15 -20
View File
@@ -408,18 +408,9 @@ func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.Err
// Sync bucket policy to the policy engine for evaluation
s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy)
// Load CORS configuration from bucket directory content
if corsConfig, err := s3a.loadCORSFromBucketContent(bucket); err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
// Missing metadata is not an error; fall back cleanly
glog.V(2).Infof("CORS metadata not found for bucket %s, falling back to default behavior", bucket)
} else {
// Log parsing or validation errors
glog.Errorf("Failed to load CORS configuration for bucket %s: %v", bucket, err)
}
} else {
config.CORS = corsConfig
}
// Parse CORS configuration directly from the entry's Content field.
// This avoids a redundant RPC call since we already have the entry.
config.CORS = parseCORSFromEntryContent(entry.Content)
// Cache the result
s3a.bucketConfigCache.Set(bucket, config)
@@ -588,15 +579,19 @@ func (s3a *S3ApiServer) setBucketOwnership(bucket, ownership string) s3err.Error
})
}
// loadCORSFromBucketContent loads CORS configuration from bucket directory content
func (s3a *S3ApiServer) loadCORSFromBucketContent(bucket string) (*cors.CORSConfiguration, error) {
metadata, err := s3a.GetBucketMetadata(bucket)
if err != nil {
return nil, err
// parseCORSFromEntryContent parses CORS configuration directly from an entry's Content field.
// This avoids a separate RPC call when the entry is already available (e.g., from a
// subscription event or a prior getBucketEntry call).
func parseCORSFromEntryContent(content []byte) *cors.CORSConfiguration {
if len(content) == 0 {
return nil
}
// Note: corsConfig can be nil if no CORS configuration is set, which is valid
return metadata.CORS, nil
var protoMetadata s3_pb.BucketMetadata
if err := proto.Unmarshal(content, &protoMetadata); err != nil {
glog.Errorf("parseCORSFromEntryContent: failed to unmarshal protobuf metadata: %v", err)
return nil
}
return corsConfigFromProto(protoMetadata.Cors)
}
// getCORSConfiguration retrieves CORS configuration with caching
+7 -3
View File
@@ -49,6 +49,10 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(rawCopySource, cpSrcPath)
glog.V(3).Infof("CopyObjectHandler %s %s (version: %s) => %s %s", srcBucket, srcObject, srcVersionId, dstBucket, dstObject)
if len(dstObject) > s3_constants.MaxS3ObjectKeyLength {
s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError)
return
}
if err := s3a.validateTableBucketObjectPath(dstBucket, dstObject); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
@@ -347,7 +351,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
entry.Attributes = dstEntry.Attributes
entry.Extended = dstEntry.Extended
}); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
s3err.WriteErrorResponse(w, r, filerErrorToS3Error(err))
return
}
@@ -372,7 +376,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
// Check if destination exists and remove it first (S3 copy overwrites)
if exists, _ := s3a.exists(dstDir, dstName, false); exists {
if err := s3a.rm(dstDir, dstName, false, false); err != nil {
if err := s3a.rmObject(dstDir, dstName, false, false); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
@@ -383,7 +387,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
entry.Attributes = dstEntry.Attributes
entry.Extended = dstEntry.Extended
}); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
s3err.WriteErrorResponse(w, r, filerErrorToS3Error(err))
return
}
+2 -5
View File
@@ -6,7 +6,6 @@ import (
"net/http"
"strings"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -130,7 +129,7 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
dir, name := target.DirAndName()
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return doDeleteEntry(client, dir, name, true, false)
return deleteObjectEntry(client, dir, name, true, false)
// Note: Empty folder cleanup is now handled asynchronously by EmptyFolderCleaner
// which listens to metadata events and uses consistent hashing for coordination
})
@@ -345,11 +344,9 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
parentDirectoryPath, entryName := target.DirAndName()
isDeleteData, isRecursive := true, false
err := doDeleteEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
err := deleteObjectEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
if err == nil {
deletedObjects = append(deletedObjects, object)
} else if strings.Contains(err.Error(), filer.MsgFailDelNonEmptyFolder) {
deletedObjects = append(deletedObjects, object)
} else {
deleteErrors = append(deleteErrors, DeleteError{
Code: "",
+67 -69
View File
@@ -35,6 +35,11 @@ const (
func (s3a *S3ApiServer) NewMultipartUploadHandler(w http.ResponseWriter, r *http.Request) {
bucket, object := s3_constants.GetBucketAndObject(r)
if len(object) > s3_constants.MaxS3ObjectKeyLength {
s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError)
return
}
// Check if bucket exists, and create it if it doesn't (auto-create bucket)
if err := s3a.checkBucket(r, bucket); err == s3err.ErrNoSuchBucket {
// Auto-create bucket if it doesn't exist (requires Admin permission)
@@ -361,82 +366,75 @@ func (s3a *S3ApiServer) PutObjectPartHandler(w http.ResponseWriter, r *http.Requ
glog.V(2).Infof("PutObjectPartHandler %s %s %04d", bucket, uploadID, partID)
// Check for SSE-C headers in the current request first
// Verify the multipart upload exists (rejects parts after abort)
uploadEntry, err := s3a.getEntry(s3a.genUploadsFolder(bucket), uploadID)
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchUpload)
return
} else if err != nil {
glog.Errorf("Could not retrieve upload entry for %s/%s: %v", bucket, uploadID, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// Apply SSE settings from the upload entry (unless SSE-C headers are already present)
sseCustomerAlgorithm := r.Header.Get(s3_constants.AmzServerSideEncryptionCustomerAlgorithm)
if sseCustomerAlgorithm != "" {
// SSE-C part upload - headers are already present, let putToFiler handle it
} else {
// No SSE-C headers, check for SSE-KMS settings from upload directory
if uploadEntry, err := s3a.getEntry(s3a.genUploadsFolder(bucket), uploadID); err == nil {
if uploadEntry.Extended != nil {
// Check if this upload uses SSE-KMS
if keyIDBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSKeyID]; exists {
keyID := string(keyIDBytes)
if sseCustomerAlgorithm == "" && uploadEntry.Extended != nil {
if keyIDBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSKeyID]; exists {
keyID := string(keyIDBytes)
// Build SSE-KMS metadata for this part
bucketKeyEnabled := false
if bucketKeyBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSBucketKeyEnabled]; exists && string(bucketKeyBytes) == "true" {
bucketKeyEnabled = true
}
bucketKeyEnabled := false
if bucketKeyBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSBucketKeyEnabled]; exists && string(bucketKeyBytes) == "true" {
bucketKeyEnabled = true
}
var encryptionContext map[string]string
if contextBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSEncryptionContext]; exists {
// Parse the stored encryption context
if err := json.Unmarshal(contextBytes, &encryptionContext); err != nil {
glog.Errorf("Failed to parse encryption context for upload %s: %v", uploadID, err)
encryptionContext = BuildEncryptionContext(bucket, object, bucketKeyEnabled)
}
} else {
encryptionContext = BuildEncryptionContext(bucket, object, bucketKeyEnabled)
}
// Get the base IV for this multipart upload
var baseIV []byte
if baseIVBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSBaseIV]; exists {
// Decode the base64 encoded base IV
decodedIV, decodeErr := base64.StdEncoding.DecodeString(string(baseIVBytes))
if decodeErr == nil && len(decodedIV) == s3_constants.AESBlockSize {
baseIV = decodedIV
glog.V(4).Infof("Using stored base IV %x for multipart upload %s", baseIV[:8], uploadID)
} else {
glog.Errorf("Failed to decode base IV for multipart upload %s: %v (expected %d bytes, got %d)", uploadID, decodeErr, s3_constants.AESBlockSize, len(decodedIV))
}
}
// Base IV is required for SSE-KMS multipart uploads - fail if missing or invalid
if len(baseIV) == 0 {
glog.Errorf("No valid base IV found for SSE-KMS multipart upload %s - cannot proceed with encryption", uploadID)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// Add SSE-KMS headers to the request for putToFiler to handle encryption
r.Header.Set(s3_constants.AmzServerSideEncryption, "aws:kms")
r.Header.Set(s3_constants.AmzServerSideEncryptionAwsKmsKeyId, keyID)
if bucketKeyEnabled {
r.Header.Set(s3_constants.AmzServerSideEncryptionBucketKeyEnabled, "true")
}
if len(encryptionContext) > 0 {
if contextJSON, err := json.Marshal(encryptionContext); err == nil {
r.Header.Set(s3_constants.AmzServerSideEncryptionContext, base64.StdEncoding.EncodeToString(contextJSON))
}
}
// Pass the base IV to putToFiler via header
r.Header.Set(s3_constants.SeaweedFSSSEKMSBaseIVHeader, base64.StdEncoding.EncodeToString(baseIV))
var encryptionContext map[string]string
if contextBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSEncryptionContext]; exists {
if err := json.Unmarshal(contextBytes, &encryptionContext); err != nil {
glog.Errorf("Failed to parse encryption context for upload %s: %v", uploadID, err)
encryptionContext = BuildEncryptionContext(bucket, object, bucketKeyEnabled)
}
} else {
encryptionContext = BuildEncryptionContext(bucket, object, bucketKeyEnabled)
}
var baseIV []byte
if baseIVBytes, exists := uploadEntry.Extended[s3_constants.SeaweedFSSSEKMSBaseIV]; exists {
decodedIV, decodeErr := base64.StdEncoding.DecodeString(string(baseIVBytes))
if decodeErr != nil {
glog.Errorf("Failed to decode base IV for multipart upload %s: %v", uploadID, decodeErr)
} else if len(decodedIV) != s3_constants.AESBlockSize {
glog.Errorf("Invalid base IV length for multipart upload %s: expected %d bytes, got %d", uploadID, s3_constants.AESBlockSize, len(decodedIV))
} else {
// Check if this upload uses SSE-S3
if err := s3a.handleSSES3MultipartHeaders(r, uploadEntry, uploadID); err != nil {
glog.Errorf("Failed to setup SSE-S3 multipart headers: %v", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
baseIV = decodedIV
glog.V(4).Infof("Using stored base IV %x for multipart upload %s", baseIV[:8], uploadID)
}
}
} else if !errors.Is(err, filer_pb.ErrNotFound) {
// Log unexpected errors (but not "not found" which is normal for non-SSE uploads)
glog.V(3).Infof("Could not retrieve upload entry for %s/%s: %v (may be non-SSE upload)", bucket, uploadID, err)
if len(baseIV) == 0 {
glog.Errorf("No valid base IV found for SSE-KMS multipart upload %s - cannot proceed with encryption", uploadID)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
r.Header.Set(s3_constants.AmzServerSideEncryption, "aws:kms")
r.Header.Set(s3_constants.AmzServerSideEncryptionAwsKmsKeyId, keyID)
if bucketKeyEnabled {
r.Header.Set(s3_constants.AmzServerSideEncryptionBucketKeyEnabled, "true")
}
if len(encryptionContext) > 0 {
if contextJSON, err := json.Marshal(encryptionContext); err == nil {
r.Header.Set(s3_constants.AmzServerSideEncryptionContext, base64.StdEncoding.EncodeToString(contextJSON))
}
}
r.Header.Set(s3_constants.SeaweedFSSSEKMSBaseIVHeader, base64.StdEncoding.EncodeToString(baseIV))
} else {
if err := s3a.handleSSES3MultipartHeaders(r, uploadEntry, uploadID); err != nil {
glog.Errorf("Failed to setup SSE-S3 multipart headers: %v", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
}
}

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