Browse Source

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
pull/8773/head
Chris Lu 15 hours ago
committed by GitHub
parent
commit
94bfa2b340
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .github/workflows/fuse-integration.yml
  2. 260
      docker/compose/test-git-on-mount.sh
  3. 32
      other/java/client/src/main/proto/filer.proto
  4. 197
      test/fuse_integration/framework_test.go
  5. 348
      test/fuse_integration/git_operations_test.go
  6. 30
      test/fuse_integration/posix_file_lock_test.go
  7. 2
      weed/filer/empty_folder_cleanup/empty_folder_cleaner.go
  8. 27
      weed/filer/filerstore_hardlink.go
  9. 10
      weed/filer/filerstore_wrapper.go
  10. 1
      weed/mount/filehandle.go
  11. 11
      weed/mount/filehandle_map.go
  12. 27
      weed/mount/weedfs.go
  13. 8
      weed/mount/weedfs_access.go
  14. 21
      weed/mount/weedfs_async_flush.go
  15. 53
      weed/mount/weedfs_dir_mkrm.go
  16. 80
      weed/mount/weedfs_file_mkrm.go
  17. 93
      weed/mount/weedfs_file_sync.go
  18. 51
      weed/mount/weedfs_link.go
  19. 98
      weed/mount/weedfs_metadata_flush.go
  20. 124
      weed/mount/weedfs_rename.go
  21. 66
      weed/mount/weedfs_stream_helpers.go
  22. 506
      weed/mount/weedfs_stream_mutate.go
  23. 19
      weed/mount/weedfs_symlink.go
  24. 31
      weed/mount/wfs_save.go
  25. 32
      weed/pb/filer.proto
  26. 463
      weed/pb/filer_pb/filer.pb.go
  27. 38
      weed/pb/filer_pb/filer_grpc.pb.go
  28. 6
      weed/server/filer_grpc_server.go
  29. 161
      weed/server/filer_grpc_server_stream_mutate.go
  30. 4
      weed/storage/needle_map_leveldb.go

1
.github/workflows/fuse-integration.yml

@ -77,4 +77,5 @@ jobs:
name: fuse-integration-test-results
path: |
/tmp/fuse-test-output.log
/tmp/seaweedfs-fuse-logs/
retention-days: 7

260
docker/compose/test-git-on-mount.sh

@ -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

32
other/java/client/src/main/proto/filer.proto

@ -33,6 +33,9 @@ service SeaweedFiler {
rpc StreamRenameEntry (StreamRenameEntryRequest) returns (stream StreamRenameEntryResponse) {
}
rpc StreamMutateEntry (stream StreamMutateEntryRequest) returns (stream StreamMutateEntryResponse) {
}
rpc AssignVolume (AssignVolumeRequest) returns (AssignVolumeResponse) {
}
@ -535,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
}

197
test/fuse_integration/framework_test.go

@ -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,
@ -119,56 +107,28 @@ func freePort(t *testing.T) int {
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)
}
// 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 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)
}
// 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
@ -186,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
@ -238,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.
args := []string{
"master",
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(f.masterPort),
"-mdir=" + filepath.Join(f.dataDir, "master"),
}
if config.EnableDebug {
args = append(args, "-v=4")
}
proc, err := f.startProcess("master", 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
// 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)
}
f.volumeProcess = proc
return nil
}
// startFiler starts the SeaweedFS filer server
func (f *FuseTestFramework) startFiler(config *TestConfig) error {
// startMini starts "weed mini" which runs master+volume+filer in one process.
func (f *FuseTestFramework) startMini(config *TestConfig) error {
args := []string{
"filer",
"-master=127.0.0.1:" + strconv.Itoa(f.masterPort),
"mini",
"-dir=" + f.dataDir,
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(f.filerPort),
"-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("filer", args)
proc, err := f.startProcess("mini", args)
if err != nil {
return err
}
f.filerProcess = proc
f.miniProcess = proc
return nil
}
@ -386,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
}
@ -399,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",
}
@ -422,7 +360,6 @@ func findWeedBinary() string {
}
}
// Default fallback — will fail with a clear "not found" at exec time.
return "weed"
}

348
test/fuse_integration/git_operations_test.go

@ -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
}

30
test/fuse_integration/posix_file_lock_test.go

@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@ -638,7 +639,7 @@ func testConcurrentLockContention(t *testing.T, fw *FuseTestFramework) {
openWithRetry := func(flags int) (*os.File, error) {
var openErr error
for attempt := 0; attempt < 50; attempt++ {
for attempt := 0; attempt < 400; attempt++ {
file, err := os.OpenFile(path, flags, 0)
if err == nil {
return file, nil
@ -647,7 +648,9 @@ func testConcurrentLockContention(t *testing.T, fw *FuseTestFramework) {
if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOENT) {
return nil, err
}
time.Sleep(10 * time.Millisecond)
// Refresh parent directory entries in case the mount cache is stale.
_, _ = os.ReadDir(filepath.Dir(path))
time.Sleep(50 * time.Millisecond)
}
return nil, openErr
}
@ -717,14 +720,35 @@ func testConcurrentLockContention(t *testing.T, fw *FuseTestFramework) {
}
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()
@ -734,7 +758,7 @@ func testConcurrentLockContention(t *testing.T, fw *FuseTestFramework) {
return false
}
return len(data) == expectedBytes
}, 5*time.Second, 50*time.Millisecond, "file should eventually contain exactly %d records from all workers", expectedLines)
}, 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)

2
weed/filer/empty_folder_cleanup/empty_folder_cleaner.go

@ -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
}

27
weed/filer/filerstore_hardlink.go

@ -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
weed/filer/filerstore_wrapper.go

@ -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
}

1
weed/mount/filehandle.go

@ -36,6 +36,7 @@ type FileHandle struct {
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

11
weed/mount/filehandle_map.go

@ -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()

27
weed/mount/weedfs.go

@ -133,6 +133,11 @@ type WFS struct {
// 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 (
@ -289,6 +294,7 @@ func NewSeaweedFileSystem(option *Option) *WFS {
}
wfs.startAsyncFlushWorkers(numWorkers)
}
wfs.streamMutate = newStreamMutateMux(wfs)
wfs.copyBufferPool.New = func() any {
return make([]byte, option.ChunkSizeLimit)
}
@ -404,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
}

8
weed/mount/weedfs_access.go

@ -40,7 +40,13 @@ func (wfs *WFS) Access(cancel <-chan struct{}, input *fuse.AccessIn) (code fuse.
if entry == nil || entry.Attributes == nil {
return fuse.EIO
}
if hasAccess(input.Uid, input.Gid, entry.Attributes.Uid, entry.Attributes.Gid, entry.Attributes.FileMode, input.Mask) {
// 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

21
weed/mount/weedfs_async_flush.go

@ -51,6 +51,9 @@ func (wfs *WFS) processAsyncFlushItem(item *asyncFlushItem) {
// This enables close() to return immediately for small file workloads (e.g., rsync),
// while the actual I/O happens concurrently in the background.
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.
@ -65,8 +68,19 @@ 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.
//
@ -121,4 +135,7 @@ func (wfs *WFS) WaitForAsyncFlush() {
if wfs.asyncFlushCh != nil {
close(wfs.asyncFlushCh)
}
if wfs.streamMutate != nil {
wfs.streamMutate.Close()
}
}

53
weed/mount/weedfs_dir_mkrm.go

@ -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)
}

80
weed/mount/weedfs_file_mkrm.go

@ -41,6 +41,8 @@ func (wfs *WFS) Create(cancel <-chan struct{}, in *fuse.CreateIn, name string, o
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)
@ -170,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
@ -192,15 +220,6 @@ 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
@ -220,7 +239,13 @@ func (wfs *WFS) createRegularFile(dirFullPath util.FullPath, name string, mode u
if parentEntry == nil || parentEntry.Attributes == nil {
return 0, nil, fuse.EIO
}
if !hasAccess(uid, gid, parentEntry.Attributes.Uid, parentEntry.Attributes.Gid, parentEntry.Attributes.FileMode, fuse.W_OK|fuse.X_OK) {
// 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)
}
@ -264,24 +289,21 @@ func (wfs *WFS) createRegularFile(dirFullPath util.FullPath, name string, mode u
return inode, newEntry, fuse.OK
}
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,
}
wfs.mapPbIdFromLocalToFiler(newEntry)
defer wfs.mapPbIdFromFilerToLocal(newEntry)
glog.V(1).Infof("createFile: %v", request)
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
glog.V(0).Infof("createFile %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("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)
@ -291,9 +313,7 @@ func (wfs *WFS) createRegularFile(dirFullPath util.FullPath, name string, mode u
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
}
wfs.inodeToPath.TouchDirectory(dirFullPath)
return nil
})
}
glog.V(3).Infof("createFile %s: %v", entryFullPath, err)

93
weed/mount/weedfs_file_sync.go

@ -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"
)
/**
@ -168,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...)
// 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,
}
wfs.mapPbIdFromLocalToFiler(request.Entry)
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
wfs.mapPbIdFromLocalToFiler(request.Entry)
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)
}
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

51
weed/mount/weedfs_link.go

@ -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
}
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)
} 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)
}
}
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

98
weed/mount/weedfs_metadata_flush.go

@ -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
}
entry := fh.GetEntry()
if entry == nil {
return nil
}
entry.Name = name
// Separate manifest and non-manifest chunks
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(chunks)
if entry.Attributes != nil {
entry.Attributes.Mtime = time.Now().Unix()
}
// Compact chunks to remove fully overlapped ones
compactedChunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
// 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
}
// 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)
}
// Separate manifest and non-manifest chunks
manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(chunks)
entry.Chunks = append(compactedChunks, manifestChunks...)
// Compact chunks to remove fully overlapped ones
compactedChunks, _ := filer.CompactFileChunks(context.Background(), wfs.LookupFn(), nonManifestChunks)
request := &filer_pb.CreateEntryRequest{
Directory: string(dir),
Entry: entry.GetEntry(),
Signatures: []int32{wfs.signature},
SkipCheckParentDirectory: true,
}
// 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)
}
wfs.mapPbIdFromLocalToFiler(request.Entry)
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
entry.Chunks = append(compactedChunks, manifestChunks...)
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
if err != nil {
return err
}
// 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,
}
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()))
return nil
})
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

124
weed/mount/weedfs_rename.go

@ -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)
// 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},
}
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)
}
// 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
}
if err = wfs.handleRenameResponse(ctx, resp); err != nil {
glog.V(0).Infof("dir Rename %s => %s : %v", oldPath, newPath, err)
return err
}
}
// 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)
}
return nil
// update remote filer
request := &filer_pb.StreamRenameEntryRequest{
OldDirectory: string(oldDir),
OldName: oldName,
NewDirectory: string(newDir),
NewName: newName,
Signatures: []int32{wfs.signature},
}
})
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)

66
weed/mount/weedfs_stream_helpers.go

@ -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
weed/mount/weedfs_stream_mutate.go

@ -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()
}

19
weed/mount/weedfs_symlink.go

@ -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

31
weed/mount/wfs_save.go

@ -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)

32
weed/pb/filer.proto

@ -33,6 +33,9 @@ service SeaweedFiler {
rpc StreamRenameEntry (StreamRenameEntryRequest) returns (stream StreamRenameEntryResponse) {
}
rpc StreamMutateEntry (stream StreamMutateEntryRequest) returns (stream StreamMutateEntryResponse) {
}
rpc AssignVolume (AssignVolumeRequest) returns (AssignVolumeResponse) {
}
@ -535,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
}

463
weed/pb/filer_pb/filer.pb.go

@ -4314,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 {
@ -4326,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)
}
@ -4338,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 {
@ -4392,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)
}
@ -4404,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 {
@ -4915,7 +5183,27 @@ 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" +
@ -4929,7 +5217,7 @@ const file_filer_proto_rawDesc = "" +
"\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\xf7\x10\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" +
@ -4938,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" +
@ -4973,7 +5262,7 @@ func file_filer_proto_rawDescGZIP() []byte {
}
var file_filer_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_filer_proto_msgTypes = make([]protoimpl.MessageInfo, 71)
var file_filer_proto_msgTypes = make([]protoimpl.MessageInfo, 73)
var file_filer_proto_goTypes = []any{
(SSEType)(0), // 0: filer_pb.SSEType
(FilerError)(0), // 1: filer_pb.FilerError
@ -5043,18 +5332,20 @@ var file_filer_proto_goTypes = []any{
(*Lock)(nil), // 65: filer_pb.Lock
(*TransferLocksRequest)(nil), // 66: filer_pb.TransferLocksRequest
(*TransferLocksResponse)(nil), // 67: filer_pb.TransferLocksResponse
nil, // 68: filer_pb.Entry.ExtendedEntry
nil, // 69: filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
nil, // 70: filer_pb.LookupVolumeResponse.LocationsMapEntry
(*LocateBrokerResponse_Resource)(nil), // 71: filer_pb.LocateBrokerResponse.Resource
(*FilerConf_PathConf)(nil), // 72: filer_pb.FilerConf.PathConf
(*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{
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
68, // 4: filer_pb.Entry.extended:type_name -> filer_pb.Entry.ExtendedEntry
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
@ -5067,78 +5358,88 @@ var file_filer_proto_depIdxs = []int32{
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
69, // 17: filer_pb.UpdateEntryRequest.expected_extended:type_name -> filer_pb.UpdateEntryRequest.ExpectedExtendedEntry
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
70, // 24: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
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
71, // 28: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
72, // 29: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
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
29, // 33: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
2, // 34: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
4, // 35: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
14, // 36: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
16, // 37: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
18, // 38: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
20, // 39: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
22, // 40: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
24, // 41: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
26, // 42: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
28, // 43: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
33, // 44: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
35, // 45: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
37, // 46: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
39, // 47: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
41, // 48: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
45, // 49: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
43, // 50: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
43, // 51: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
52, // 52: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
54, // 53: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
57, // 54: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
59, // 55: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
61, // 56: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
63, // 57: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
66, // 58: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
3, // 59: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
5, // 60: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
15, // 61: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
17, // 62: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
19, // 63: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
21, // 64: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
23, // 65: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
25, // 66: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
27, // 67: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
31, // 68: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
34, // 69: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
36, // 70: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
38, // 71: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
40, // 72: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
42, // 73: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
46, // 74: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
44, // 75: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
44, // 76: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
53, // 77: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
55, // 78: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
58, // 79: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
60, // 80: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
62, // 81: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
64, // 82: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
67, // 83: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
59, // [59:84] is the sub-list for method output_type
34, // [34:59] is the sub-list for method input_type
34, // [34:34] is the sub-list for extension type_name
34, // [34:34] is the sub-list for extension extendee
0, // [0:34] is the sub-list for field type_name
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() }
@ -5146,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: 2,
NumMessages: 71,
NumMessages: 73,
NumExtensions: 0,
NumServices: 1,
},

38
weed/pb/filer_pb/filer_grpc.pb.go

@ -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,

6
weed/server/filer_grpc_server.go

@ -160,6 +160,9 @@ func (fs *FilerServer) lookupFileId(ctx context.Context, fileId string) (targetU
func (fs *FilerServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (resp *filer_pb.CreateEntryResponse, err error) {
glog.V(4).InfofCtx(ctx, "CreateEntry %v/%v", req.Directory, req.Entry.Name)
if len(req.Entry.HardLinkId) > 0 {
glog.V(4).InfofCtx(ctx, "CreateEntry %s/%s with HardLinkId %x counter=%d", req.Directory, req.Entry.Name, req.Entry.HardLinkId, req.Entry.HardLinkCounter)
}
resp = &filer_pb.CreateEntryResponse{}
@ -212,6 +215,9 @@ func (fs *FilerServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntr
func (fs *FilerServer) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
glog.V(4).InfofCtx(ctx, "UpdateEntry %v", req)
if len(req.Entry.HardLinkId) > 0 {
glog.V(4).InfofCtx(ctx, "UpdateEntry %s/%s with HardLinkId %x counter=%d", req.Directory, req.Entry.Name, req.Entry.HardLinkId, req.Entry.HardLinkCounter)
}
fullpath := util.Join(req.Directory, req.Entry.Name)
entry, err := fs.filer.FindEntry(ctx, util.FullPath(fullpath))

161
weed/server/filer_grpc_server_stream_mutate.go

@ -0,0 +1,161 @@
package weed_server
import (
"context"
"io"
"strings"
"syscall"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
func (fs *FilerServer) StreamMutateEntry(stream grpc.BidiStreamingServer[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse]) error {
for {
req, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
switch r := req.Request.(type) {
case *filer_pb.StreamMutateEntryRequest_CreateRequest:
resp, createErr := fs.CreateEntry(stream.Context(), r.CreateRequest)
if createErr != nil {
resp = &filer_pb.CreateEntryResponse{Error: createErr.Error()}
}
streamResp := &filer_pb.StreamMutateEntryResponse{
RequestId: req.RequestId,
IsLast: true,
Response: &filer_pb.StreamMutateEntryResponse_CreateResponse{CreateResponse: resp},
}
if resp.Error != "" {
streamResp.Error = resp.Error
streamResp.Errno = int32(syscall.EIO)
}
if sendErr := stream.Send(streamResp); sendErr != nil {
return sendErr
}
case *filer_pb.StreamMutateEntryRequest_UpdateRequest:
resp, updateErr := fs.UpdateEntry(stream.Context(), r.UpdateRequest)
if updateErr != nil {
resp = &filer_pb.UpdateEntryResponse{}
}
streamResp := &filer_pb.StreamMutateEntryResponse{
RequestId: req.RequestId,
IsLast: true,
Response: &filer_pb.StreamMutateEntryResponse_UpdateResponse{UpdateResponse: resp},
}
if updateErr != nil {
streamResp.Error = updateErr.Error()
streamResp.Errno = int32(syscall.EIO)
}
if sendErr := stream.Send(streamResp); sendErr != nil {
return sendErr
}
case *filer_pb.StreamMutateEntryRequest_DeleteRequest:
resp, deleteErr := fs.DeleteEntry(stream.Context(), r.DeleteRequest)
if deleteErr != nil {
resp = &filer_pb.DeleteEntryResponse{Error: deleteErr.Error()}
}
streamResp := &filer_pb.StreamMutateEntryResponse{
RequestId: req.RequestId,
IsLast: true,
Response: &filer_pb.StreamMutateEntryResponse_DeleteResponse{DeleteResponse: resp},
}
if resp.Error != "" {
streamResp.Error = resp.Error
streamResp.Errno = int32(syscall.EIO)
}
if sendErr := stream.Send(streamResp); sendErr != nil {
return sendErr
}
case *filer_pb.StreamMutateEntryRequest_RenameRequest:
if err := fs.handleStreamMutateRename(stream, req.RequestId, r.RenameRequest); err != nil {
return err
}
default:
glog.Warningf("StreamMutateEntry: unknown request type %T", req.Request)
}
}
}
// handleStreamMutateRename delegates to the existing StreamRenameEntry logic
// using a proxy stream that converts StreamRenameEntryResponse events into
// StreamMutateEntryResponse messages on the parent bidi stream.
func (fs *FilerServer) handleStreamMutateRename(
parent grpc.BidiStreamingServer[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse],
requestId uint64,
req *filer_pb.StreamRenameEntryRequest,
) error {
proxy := &renameStreamProxy{parent: parent, requestId: requestId}
renameErr := fs.StreamRenameEntry(req, proxy)
// Always send a final is_last=true to signal rename completion.
finalResp := &filer_pb.StreamMutateEntryResponse{
RequestId: requestId,
IsLast: true,
Response: &filer_pb.StreamMutateEntryResponse_RenameResponse{
RenameResponse: &filer_pb.StreamRenameEntryResponse{},
},
}
if renameErr != nil {
finalResp.Error = renameErr.Error()
finalResp.Errno = renameErrno(renameErr)
glog.V(0).Infof("StreamMutateEntry rename: %v", renameErr)
}
if sendErr := parent.Send(finalResp); sendErr != nil {
return sendErr
}
return nil
}
// renameStreamProxy adapts the bidi StreamMutateEntry stream to look like a
// SeaweedFiler_StreamRenameEntryServer, which is what StreamRenameEntry and
// moveEntry expect. Each Send() call forwards the response as a non-final
// StreamMutateEntryResponse.
type renameStreamProxy struct {
parent grpc.BidiStreamingServer[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse]
requestId uint64
}
func (p *renameStreamProxy) Send(resp *filer_pb.StreamRenameEntryResponse) error {
return p.parent.Send(&filer_pb.StreamMutateEntryResponse{
RequestId: p.requestId,
IsLast: false,
Response: &filer_pb.StreamMutateEntryResponse_RenameResponse{RenameResponse: resp},
})
}
func (p *renameStreamProxy) Context() context.Context {
return p.parent.Context()
}
func (p *renameStreamProxy) SendMsg(m any) error { return p.parent.SendMsg(m) }
func (p *renameStreamProxy) RecvMsg(m any) error { return p.parent.RecvMsg(m) }
func (p *renameStreamProxy) SetHeader(md metadata.MD) error { return p.parent.SetHeader(md) }
func (p *renameStreamProxy) SendHeader(md metadata.MD) error { return p.parent.SendHeader(md) }
func (p *renameStreamProxy) SetTrailer(md metadata.MD) { p.parent.SetTrailer(md) }
// renameErrno maps a rename error to a POSIX errno for the client.
func renameErrno(err error) int32 {
msg := err.Error()
switch {
case strings.Contains(msg, "not found"):
return int32(syscall.ENOENT)
case strings.Contains(msg, "not empty"):
return int32(syscall.ENOTEMPTY)
case strings.Contains(msg, "not directory"):
return int32(syscall.ENOTDIR)
default:
return int32(syscall.EIO)
}
}

4
weed/storage/needle_map_leveldb.go

@ -63,7 +63,7 @@ func NewLevelDbNeedleMap(dbFileName string, indexFile *os.File, opts *opt.Option
return
}
}
glog.V(0).Infof("Loading %s... , watermark: %d", dbFileName, getWatermark(m.db))
glog.V(1).Infof("Loading %s... , watermark: %d", dbFileName, getWatermark(m.db))
m.recordCount = uint64(m.indexFileOffset / NeedleMapEntrySize)
watermark := (m.recordCount / watermarkBatchSize) * watermarkBatchSize
err = setWatermark(m.db, watermark)
@ -119,7 +119,7 @@ func generateLevelDbFile(dbFileName string, indexFile *os.File) error {
if watermark*NeedleMapEntrySize > uint64(stat.Size()) {
glog.Warningf("wrong watermark %d for filesize %d", watermark, stat.Size())
}
glog.V(0).Infof("generateLevelDbFile %s, watermark %d, num of entries:%d", dbFileName, watermark, (uint64(stat.Size())-watermark*NeedleMapEntrySize)/NeedleMapEntrySize)
glog.V(1).Infof("generateLevelDbFile %s, watermark %d, num of entries:%d", dbFileName, watermark, (uint64(stat.Size())-watermark*NeedleMapEntrySize)/NeedleMapEntrySize)
}
return idx.WalkIndexFile(indexFile, watermark, func(key NeedleId, offset Offset, size Size) error {
if !offset.IsZero() && !size.IsDeleted() {

Loading…
Cancel
Save