diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index eff57f32a..07cc5243d 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -30,7 +30,7 @@ jobs: - name: Reject production imports of test support run: bash scripts/check-production-imports.sh - name: Run race tests outside full-length gRPC sequences - run: go test -race ./... -skip "$GRPC_SEQUENCE_TESTS" + run: go test -race -timeout=30m ./... -skip "$GRPC_SEQUENCE_TESTS" env: # Fail, rather than skip, the kernel-mount tests when FUSE is unusable. ELASTICKV_REQUIRE_FUSE: "1" @@ -38,4 +38,4 @@ jobs: # These unchanged 10,000-iteration tests need a fresh package budget # after the serial migration/snapshot acceptance tests have finished. - name: Run full-length gRPC consistency sequences - run: go test -race ./adapter -run "$GRPC_SEQUENCE_TESTS" + run: go test -race -timeout=30m ./adapter -run "$GRPC_SEQUENCE_TESTS" diff --git a/.github/workflows/jepsen-test-scheduled.yml b/.github/workflows/jepsen-test-scheduled.yml index b4ea08587..03741b7d8 100644 --- a/.github/workflows/jepsen-test-scheduled.yml +++ b/.github/workflows/jepsen-test-scheduled.yml @@ -36,9 +36,35 @@ permissions: contents: read jobs: test: + name: test (${{ matrix.workload }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workload: + - unit + - redis + - redis-zset + - dynamodb + - dynamodb-type-string + - dynamodb-type-number + - dynamodb-type-binary + - dynamodb-type-bool + - dynamodb-type-null + - dynamodb-type-string-set + - dynamodb-type-number-set + - dynamodb-type-binary-set + - dynamodb-type-list + - dynamodb-type-map + - s3 + - filesystem-cross-mount + - filesystem-sqlite-check + - filesystem-overwrite + - filesystem-rename + - sqlite env: GOCACHE: /tmp/go-build + WORKLOAD: ${{ matrix.workload }} steps: - uses: actions/checkout@v7 with: @@ -74,20 +100,15 @@ jobs: restore-keys: | ${{ runner.os }}-maven- - name: Pre-fetch Go modules and build binaries - # The demo cluster is built rather than started through `go run`: - # `go run` execs the compiled program as a child and does not forward - # SIGTERM to it, so killing the recorded `go run` PID would leave the - # demo listening and block the filesystem cluster below. The server - # binary serves that filesystem stress phase: the demo cluster - # (cmd/server/demo.go) has no --filesystemMount, so the FUSE workload - # runs against three real server processes instead. + if: matrix.workload != 'unit' run: | mkdir -p "$GOCACHE" /tmp/go-tmp - GOPATH=$(go env GOPATH) export GOCACHE GOTMPDIR=/tmp/go-tmp go mod download - go build -o /tmp/elastickv-demo ./cmd/server - go build -o /tmp/elastickv-bin . + case "$WORKLOAD" in + filesystem-*|sqlite) go build -o /tmp/elastickv-bin . ;; + *) go build -o /tmp/elastickv-demo ./cmd/server ;; + esac # Warm the Maven cache up front so unit tests / workloads start # against a populated ~/.m2. `lein deps` is wrapped with a retry # loop because Maven Central transiently returns 429 during peak @@ -111,16 +132,18 @@ jobs: sleep "$sleep_secs" done - name: Run Jepsen unit tests + if: matrix.workload == 'unit' working-directory: jepsen run: ~/lein test - name: Launch demo cluster + if: matrix.workload != 'unit' && !startsWith(matrix.workload, 'filesystem-') && matrix.workload != 'sqlite' run: | set -euo pipefail nohup /tmp/elastickv-demo > /tmp/elastickv-demo.log 2>&1 & echo $! > /tmp/elastickv-demo.pid echo "Waiting for redis (63791-63793), dynamo (63801-63803), and s3 (63901-63903) listeners..." - for i in {1..90}; do + for _ in {1..90}; do if nc -z 127.0.0.1 63791 && nc -z 127.0.0.1 63792 && nc -z 127.0.0.1 63793 \ && nc -z 127.0.0.1 63801 && nc -z 127.0.0.1 63802 && nc -z 127.0.0.1 63803 \ && nc -z 127.0.0.1 63901 && nc -z 127.0.0.1 63902 && nc -z 127.0.0.1 63903; then @@ -134,11 +157,12 @@ jobs: tail -n 200 /tmp/elastickv-demo.log || true exit 1 - name: Run Redis Jepsen workload against elastickv + if: matrix.workload == 'redis' working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.redis-workload \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.redis-workload \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count ${{ inputs.key-count || '16' }} \ @@ -147,21 +171,23 @@ jobs: --ports 63791,63792,63793 \ --host 127.0.0.1 - name: Run Redis ZSet safety Jepsen workload against elastickv + if: matrix.workload == 'redis-zset' working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.redis-zset-safety-workload \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.redis-zset-safety-workload \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --ports 63791,63792,63793 \ --host 127.0.0.1 - name: Run DynamoDB Jepsen workload against elastickv + if: matrix.workload == 'dynamodb' working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.dynamodb-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.dynamodb-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count ${{ inputs.key-count || '16' }} \ @@ -169,69 +195,35 @@ jobs: --max-txn-length ${{ inputs.max-txn-length || '4' }} \ --dynamo-ports 63801,63802,63803 \ --host 127.0.0.1 - - name: Run DynamoDB per-type Jepsen workloads against elastickv + - name: Run DynamoDB per-type Jepsen workload against elastickv + if: startsWith(matrix.workload, 'dynamodb-type-') working-directory: jepsen - # The per-type sweep is a coverage check across all 10 attribute - # types, not the deep stress run — it uses its own shorter - # time-limit so the 10-type loop fits comfortably inside the job - # timeout regardless of the workflow_dispatch time-limit input. - # The per-invocation `timeout` is derived from TYPE_TL + buffer - # so bumping TYPE_TL never races against the outer timeout. - timeout-minutes: 30 + # Each attribute type uses a fresh cluster on its own runner. + timeout-minutes: 6 env: - # Per-type sweep is a coverage check, not the deep stress run, so - # it uses its own shorter runtime and smaller history density than - # the parent dynamodb-workload step. Keeping per-key ops modest - # also keeps Knossos's linearizability analysis inside its - # time budget (dense histories cause :valid? :unknown verdicts). TYPE_TL: "60" TYPE_CONCURRENCY: "4" TYPE_KEY_COUNT: "8" TYPE_MAX_WRITES: "80" run: | - # Run every type independently: one failure does not stop - # the sweep so the final summary shows which specific types - # passed/failed. The step still fails if any type failed. PER_TYPE_TIMEOUT=$((TYPE_TL + 180)) - declare -A RESULT - FAILED=() - for t in string number binary bool null string-set number-set binary-set list map; do - echo "::group::value-type=${t}" - set +e - timeout "${PER_TYPE_TIMEOUT}" ~/lein run -m elastickv.dynamodb-types-workload --local \ - --time-limit "${TYPE_TL}" \ - --rate ${{ inputs.rate || '5' }} \ - --concurrency "${TYPE_CONCURRENCY}" \ - --key-count "${TYPE_KEY_COUNT}" \ - --max-writes-per-key "${TYPE_MAX_WRITES}" \ - --value-type "${t}" \ - --dynamo-ports 63801,63802,63803 \ - --host 127.0.0.1 - rc=$? - set -e - if [ "$rc" -eq 0 ]; then - RESULT[$t]="pass" - else - RESULT[$t]="fail(${rc})" - FAILED+=("$t") - fi - echo "::endgroup::" - done - echo - echo "=== per-type jepsen summary ===" - for t in string number binary bool null string-set number-set binary-set list map; do - printf ' %-12s %s\n' "$t" "${RESULT[$t]}" - done - if [ ${#FAILED[@]} -ne 0 ]; then - echo "FAILED types: ${FAILED[*]}" - exit 1 - fi + TYPE="${WORKLOAD#dynamodb-type-}" + timeout "${PER_TYPE_TIMEOUT}" ~/lein run -m elastickv.dynamodb-types-workload --local \ + --time-limit "${TYPE_TL}" \ + --rate ${{ inputs.rate || '5' }} \ + --concurrency "${TYPE_CONCURRENCY}" \ + --key-count "${TYPE_KEY_COUNT}" \ + --max-writes-per-key "${TYPE_MAX_WRITES}" \ + --value-type "${TYPE}" \ + --dynamo-ports 63801,63802,63803 \ + --host 127.0.0.1 - name: Run S3 Jepsen workload against elastickv + if: matrix.workload == 's3' working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.s3-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.s3-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count ${{ inputs.key-count || '16' }} \ @@ -239,30 +231,8 @@ jobs: --threads-per-key 4 \ --s3-ports 63901,63902,63903 \ --host 127.0.0.1 - - name: Stop demo cluster before the filesystem cluster - # The filesystem stress phase needs --filesystemMount, which the - # single-process demo cluster does not offer, so it runs against a - # real-binary cluster on the same ports once the demo is down. The - # demo keeps its Raft state in memory; nothing is lost by stopping it. - # The PID is the demo binary itself (not a `go run` wrapper), so the - # listeners close with it; the loop below confirms that before the - # ports are reused. - run: | - if [ -f /tmp/elastickv-demo.pid ]; then - pid=$(cat /tmp/elastickv-demo.pid) - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - rm -f /tmp/elastickv-demo.pid - fi - for i in {1..30}; do - if ! nc -z 127.0.0.1 63791 && ! nc -z 127.0.0.1 63792 && ! nc -z 127.0.0.1 63793; then - exit 0 - fi - sleep 1 - done - echo "demo cluster listeners still open after 30s" >&2 - exit 1 - name: Launch filesystem cluster + if: startsWith(matrix.workload, 'filesystem-') || matrix.workload == 'sqlite' # Mirrors the cluster in jepsen-test.yml: every node mounts the shared # filesystem so the workload drives leader and follower mounts alike, # and the root directory is owned by the runner user because the @@ -284,6 +254,7 @@ jobs: --dynamoAddress "127.0.0.1:6380${node}" \ --s3Address "127.0.0.1:6390${node}" \ --filesystemMount "/tmp/elastickv-fs/fs-n${node}" \ + --filesystemStrict \ --filesystemRootUID "$(id -u)" \ --filesystemRootGID "$(id -g)" \ --metricsAddress "" \ @@ -299,7 +270,7 @@ jobs: done echo "Waiting for redis (63791-63793) listeners and FUSE mounts..." - for i in {1..90}; do + for _ in {1..90}; do if nc -z 127.0.0.1 63791 && nc -z 127.0.0.1 63792 && nc -z 127.0.0.1 63793 \ && mountpoint -q /tmp/elastickv-fs/fs-n1 && mountpoint -q /tmp/elastickv-fs/fs-n2 \ && mountpoint -q /tmp/elastickv-fs/fs-n3; then @@ -315,6 +286,7 @@ jobs: done exit 1 - name: Verify FUSE cross-mount disjoint writes without client retries + if: matrix.workload == 'filesystem-cross-mount' run: | python3 scripts/test-fuse-cross-mount.py \ /tmp/elastickv-fs/fs-n1 \ @@ -322,25 +294,20 @@ jobs: /tmp/elastickv-fs/fs-n3 \ --iterations 50 - name: Verify SQLite rollback journals without client retries + if: matrix.workload == 'filesystem-sqlite-check' run: python3 scripts/test-fuse-sqlite.py /tmp/elastickv-fs/fs-n1 --writes 30 - - name: Run filesystem Jepsen workloads against elastickv + - name: Run filesystem Jepsen workload against elastickv + if: matrix.workload == 'filesystem-overwrite' || matrix.workload == 'filesystem-rename' working-directory: jepsen - # Linearizable register over the FUSE mounts, at the same stress - # settings as the other workloads: every node mounts the same - # filesystem and each key is one file. `overwrite` writes the record - # in place (one chunk RMW); `rename` replaces it through a temp file + - # rename (namespace txn and remove-while-open). Both modes run even if - # one fails so the summary shows which mode broke. + # Each write mode uses a fresh filesystem cluster on its own runner. # See jepsen/src/elastickv/filesystem_workload.clj. - timeout-minutes: 20 + timeout-minutes: 30 env: FS_REQUESTED_KEYS: ${{ inputs.key-count || '16' }} FS_REQUESTED_WRITES: ${{ inputs.max-writes-per-key || '250' }} run: | # Knossos checks each register independently. Bound each history's # ambiguous writes while keeping the requested total write budget. - # Both write modes can accumulate enough indeterminate operations - # to exhaust Knossos at 64 writes per key. valid_java_positive_int() { [[ "$1" =~ ^[1-9][0-9]*$ ]] || return 1 [[ ${#1} -lt 10 ]] && return 0 @@ -353,67 +320,45 @@ jobs: fi bash test/filesystem_budget_test.sh source ./filesystem_budget.sh - declare -A RESULT - FAILED=() - for mode in overwrite rename; do - echo "::group::write-mode=${mode}" - filesystem_budget "$mode" "$FS_REQUESTED_KEYS" "$FS_REQUESTED_WRITES" - echo "Filesystem register budget: ${FS_KEY_COUNT} keys, ${FS_MAX_WRITES} writes per key" - set +e - timeout 480 ~/lein run -m elastickv.filesystem-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ - --rate ${{ inputs.rate || '10' }} \ - --concurrency ${{ inputs.concurrency || '8' }} \ - --key-count "$FS_KEY_COUNT" \ - --max-writes-per-key "$FS_MAX_WRITES" \ - --write-mode "${mode}" \ - --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 - rc=$? - set -e - if [ "$rc" -eq 0 ]; then - RESULT[$mode]="pass" - else - RESULT[$mode]="fail(${rc})" - FAILED+=("$mode") - fi - echo "::endgroup::" - done - echo - echo "=== filesystem jepsen summary ===" - for mode in overwrite rename; do - printf ' %-10s %s\n' "$mode" "${RESULT[$mode]}" - done - if [ ${#FAILED[@]} -ne 0 ]; then - echo "FAILED modes: ${FAILED[*]}" - exit 1 - fi + MODE="${WORKLOAD#filesystem-}" + filesystem_budget "$MODE" "$FS_REQUESTED_KEYS" "$FS_REQUESTED_WRITES" + echo "Filesystem register budget: ${FS_KEY_COUNT} keys, ${FS_MAX_WRITES} writes per key" + timeout 720 ~/lein run -m elastickv.filesystem-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ + --rate ${{ inputs.rate || '10' }} \ + --concurrency ${{ inputs.concurrency || '8' }} \ + --key-count "$FS_KEY_COUNT" \ + --max-writes-per-key "$FS_MAX_WRITES" \ + --write-mode "$MODE" \ + --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 - name: Run SQLite Jepsen workload on FUSE + if: matrix.workload == 'sqlite' working-directory: jepsen - timeout-minutes: 12 + timeout-minutes: 20 run: | - # Keep the 1024-write budget while bounding the ambiguous writes - # Knossos must explore for any one SQLite database. + # Spread the longer run across databases while bounding the + # ambiguous writes Knossos must explore for any one database. bash test/sqlite_budget_test.sh source ./sqlite_budget.sh - sqlite_budget 16 64 + sqlite_budget 24 64 echo "SQLite register budget: ${SQLITE_KEY_COUNT} keys, ${SQLITE_MAX_WRITES} writes per key" - timeout 600 ~/lein run -m elastickv.sqlite-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 900 ~/lein run -m elastickv.sqlite-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency 8 --key-count "$SQLITE_KEY_COUNT" --max-writes-per-key "$SQLITE_MAX_WRITES" \ --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 - name: Archive Jepsen store on failure - if: failure() + if: failure() && matrix.workload != 'unit' run: bash scripts/archive-jepsen-store.sh jepsen/store /tmp/jepsen-store-types.tar.gz - name: Upload Jepsen store on failure - if: failure() + if: failure() && matrix.workload != 'unit' uses: actions/upload-artifact@v7 with: - name: jepsen-store-types + name: jepsen-store-${{ matrix.workload }} path: /tmp/jepsen-store-types.tar.gz retention-days: 7 - name: Dump demo cluster log on failure - if: failure() + if: failure() && matrix.workload != 'unit' && !startsWith(matrix.workload, 'filesystem-') && matrix.workload != 'sqlite' # The previous `tail -n 500` truncated a 3-minute workload's # log down to startup-only lines, making it impossible to # correlate a Jepsen anomaly with the server-side state @@ -429,15 +374,15 @@ jobs: echo "=== full log line count ===" wc -l /tmp/elastickv-demo.log || true - name: Upload demo cluster log on failure - if: failure() + if: failure() && matrix.workload != 'unit' && !startsWith(matrix.workload, 'filesystem-') && matrix.workload != 'sqlite' uses: actions/upload-artifact@v7 with: - name: elastickv-demo-log + name: elastickv-demo-log-${{ matrix.workload }} path: /tmp/elastickv-demo.log retention-days: 14 if-no-files-found: warn - name: Dump filesystem cluster logs on failure - if: failure() + if: failure() && (startsWith(matrix.workload, 'filesystem-') || matrix.workload == 'sqlite') run: | for node in n1 n2 n3; do log=/tmp/elastickv-fs-${node}.log @@ -450,10 +395,10 @@ jobs: wc -l "$log" || true done - name: Upload filesystem cluster logs on failure - if: failure() + if: failure() && (startsWith(matrix.workload, 'filesystem-') || matrix.workload == 'sqlite') uses: actions/upload-artifact@v7 with: - name: elastickv-fs-logs + name: elastickv-fs-logs-${{ matrix.workload }} path: | /tmp/elastickv-fs-n1.log /tmp/elastickv-fs-n2.log diff --git a/.github/workflows/jepsen-test.yml b/.github/workflows/jepsen-test.yml index 1244e1d40..71888e019 100644 --- a/.github/workflows/jepsen-test.yml +++ b/.github/workflows/jepsen-test.yml @@ -287,15 +287,15 @@ jobs: # temp file + rename (namespace txn and remove-while-open). Both run # even if one fails so the log shows which mode broke. # See jepsen/src/elastickv/filesystem_workload.clj. - timeout-minutes: 6 + timeout-minutes: 10 run: | declare -A RESULT FAILED=() for mode in overwrite rename; do echo "::group::write-mode=${mode}" set +e - timeout 150 ~/lein run -m elastickv.filesystem-workload --local \ - --time-limit 5 --rate 5 --concurrency 6 \ + timeout 240 ~/lein run -m elastickv.filesystem-workload --local \ + --time-limit 30 --rate 5 --concurrency 6 \ --write-mode "${mode}" \ --mount-dirs /tmp/elastickv-ci/fs-n1,/tmp/elastickv-ci/fs-n2,/tmp/elastickv-ci/fs-n3 rc=$? @@ -319,7 +319,7 @@ jobs: fi - name: Run SQLite Jepsen workload on FUSE working-directory: jepsen - timeout-minutes: 3 + timeout-minutes: 6 run: | probe=/tmp/elastickv-ci/fs-n1/sqlite-probe.db if ! strace -f -o /tmp/elastickv-sqlite.strace sqlite3 "$probe" \ @@ -345,9 +345,9 @@ jobs: exit 1 fi done - timeout 150 ~/lein run -m elastickv.sqlite-workload --local \ - --time-limit 5 --rate 5 --concurrency 6 \ - --key-count 6 --max-writes-per-key 16 \ + timeout 240 ~/lein run -m elastickv.sqlite-workload --local \ + --time-limit 30 --rate 5 --concurrency 6 \ + --key-count 12 --max-writes-per-key 16 \ --mount-dirs /tmp/elastickv-ci/fs-n1,/tmp/elastickv-ci/fs-n2,/tmp/elastickv-ci/fs-n3 - name: Archive Jepsen store on filesystem failure if: failure() diff --git a/Dockerfile.redis-proxy b/Dockerfile.redis-proxy index a6d17b073..fa0e994de 100644 --- a/Dockerfile.redis-proxy +++ b/Dockerfile.redis-proxy @@ -2,6 +2,7 @@ FROM golang:latest AS build WORKDIR $GOPATH/src/app COPY go.mod go.sum ./ +COPY third_party/go-fuse/go.mod third_party/go-fuse/go.mod RUN go mod download COPY . . diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index d04b8eaec..de149176c 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -438,13 +438,57 @@ regression tests. ### Remaining work after the bounded legacy-path changes -The current branch adds typed forwarding of definitive write conflicts, bounded -retries for those conflicts in several filesystem service operations, a local -FUSE predecessor barrier, and mounted SQLite PERSIST/DELETE test coverage. These -changes improve the legacy path but do not route FUSE mutations through the -operation journal. In particular, `AdvanceGeneration` still fails closed, and -`Service.Fsync` still uses an attribute read rather than a durable receipt. -The tests for the legacy path cannot satisfy the strict-mode gates below. +The merged baseline forwards definitive write conflicts, retries several +filesystem service operations within bounds, tracks local FUSE predecessors, +and tests mounted SQLite PERSIST/DELETE workloads. +This branch adds a transaction-primary generation fence and optional +journal-backed paths for fixed-offset FUSE writes, setattr including size changes, +create/mknod, mkdir, open, release, lease renewal, file unlink, +empty-directory removal, and same-directory rename. +The adapter retains their successful operation IDs per inode. +Its flush and fsync paths resolve those IDs and check participant proofs. +Directory fsync checks create, mkdir, removal and rename receipts recorded for +that parent. File fsync also checks create, open and release receipts for its +inode when those optional paths are selected. +The default mount path still uses the legacy mutations. +The optional lease renewal path serializes foreground and background refreshes +with other inode mutations and blocks later fsync after an unknown renewal. +It journals at most one renewal per handle within a quarter of the configured +lease lifetime, capped at one minute. Other reads validate the current lease +without extending it. A later renewal verifies the preceding lease receipt +before removing that receipt from the local fsync set; failed verification +keeps the older receipt. +`Service.Fsync` still uses an attribute read after the adapter checks the +receipts for its supported journaled operations. +The optional receipt tracker is local to one mount and retains only successful +operations in memory. Successful replies now trigger receipt verification and +session acknowledgement after the FUSE device write attempt. +Write planning failures before publication cancel their pending attempt with a +typed result so a duplicate observes the same failure and the session can +close. An uncertain cancellation remains unknown until resolution. +The local go-fuse bridge now notifies the adapter after `Server.handleRequest` +attempts the kernel reply. The adapter verifies participant receipts before +acknowledging terminal operations and retries acknowledgements that could not +complete. Suppressed replies do not trigger acknowledgement. The explicit +`--filesystemStrict` mount option selects all supported journaled mutation +paths. Recovery of a previous incarnation requires the separate +`--filesystemStrictRecover` flag after its process has stopped. +The explicit strict mount is available for validation, but the default remains +the legacy path until the strict-mode acceptance gates below pass. + +The current Raft path persists Ready entries through `persistReadyToWAL` +before applying them. +The pinned etcd WAL calls sync for a Ready containing entries. +The Pebble FSM apply path defaults to `pebble.Sync`; its optional no-sync mode +depends on replay from the synced Raft WAL. +Local Pebble close/reopen tests recover committed write, metadata, namespace, +create and open results with their receipts. A separate process-kill test +recovers create, write, truncate and rename receipts from the Pebble WAL +without closing the store. Neither test exercises a Raft process or node +crash. +This code audit supports the receipt approach but does not close the M4 gate. +Crash tests must cover failure between WAL persistence, FSM apply, proof +visibility, FUSE reply, and the next fsync for every participant group. Complete the remaining work in dependency order: diff --git a/docs/filesystem.md b/docs/filesystem.md index b37ecbeb4..001f70439 100644 --- a/docs/filesystem.md +++ b/docs/filesystem.md @@ -79,6 +79,8 @@ flags: |---|---|---| | `--filesystemMount` | empty (disabled) | Directory to mount the filesystem on. | | `--filesystemClientID` | value of `--raftId` | Identity recorded in open-handle lease keys and in the mount log line, for attribution. Does not have to be unique: lease keys also carry a per-open handle ID. | +| `--filesystemStrict` | `false` | Use a registered operation session for supported FUSE mutations. This validation mode requires the reply-aware go-fuse bridge. | +| `--filesystemStrictRecover` | `false` | Fence and recover a prior strict session for the same client ID before registering a new one. Set this only after confirming the prior mount process has stopped. Requires `--filesystemStrict`. | | `--filesystemRootUID` | `0` | Owner UID of the root directory. Applied only when the root is first created. | | `--filesystemRootGID` | `0` | Owner GID of the root directory. Applied only when the root is first created. | | `--filesystemRootMode` | `0755` | Permission bits of the root directory. Applied only when the root is first created. | diff --git a/go.mod b/go.mod index b42641de5..27e03cbbe 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/bootjp/elastickv +replace github.com/hanwen/go-fuse/v2 => ./third_party/go-fuse + go 1.26.0 toolchain go1.27.2 diff --git a/internal/filesystem/fstest/memory.go b/internal/filesystem/fstest/memory.go index 51bb7e23c..7afbb7690 100644 --- a/internal/filesystem/fstest/memory.go +++ b/internal/filesystem/fstest/memory.go @@ -4,6 +4,7 @@ package fstest import ( "context" + "encoding/binary" "sync" "github.com/bootjp/elastickv/internal/filesystem" @@ -20,6 +21,8 @@ type MemoryDispatcher struct { store store.MVCCStore } +const commitTimestampBytes = 8 + // NewMemoryDispatcher wraps st in a dispatcher that commits every group at // the next commit timestamp. func NewMemoryDispatcher(st store.MVCCStore) *MemoryDispatcher { @@ -48,12 +51,19 @@ func (d *MemoryDispatcher) Dispatch( } mutations := make([]*store.KVPairMutation, 0, len(req.Elems)) for _, elem := range req.Elems { + value := append([]byte(nil), elem.Value...) + if elem.CommitTSValueOffset != 0 { + if len(value) < commitTimestampBytes || elem.CommitTSValueOffset > uint64(len(value)-commitTimestampBytes) { + return nil, errors.WithStack(filesystem.ErrInvalid) + } + binary.BigEndian.PutUint64(value[elem.CommitTSValueOffset:], commitTS) + } switch elem.Op { case kv.Put: mutations = append(mutations, &store.KVPairMutation{ Op: store.OpTypePut, Key: append([]byte(nil), elem.Key...), - Value: append([]byte(nil), elem.Value...), + Value: value, }) case kv.Del: mutations = append(mutations, &store.KVPairMutation{ diff --git a/internal/filesystem/fuseadapter/ack.go b/internal/filesystem/fuseadapter/ack.go new file mode 100644 index 000000000..ead4439f3 --- /dev/null +++ b/internal/filesystem/fuseadapter/ack.go @@ -0,0 +1,220 @@ +package fuseadapter + +import ( + "cmp" + "context" + "log/slog" + "slices" + "sort" + "time" + + "github.com/bootjp/elastickv/internal/filesystem" + "github.com/cockroachdb/errors" +) + +const ( + ackRetryInterval = 5 * time.Second + ackTimeout = 5 * time.Second + ackBatchSize = 64 + ackDrainInterval = 10 * time.Millisecond +) + +type replyIdentityKey struct{} +type backgroundOperationKey struct{} + +func withReplyIdentity(ctx context.Context, unique uint64) context.Context { + if unique == 0 { + return ctx + } + return context.WithValue(ctx, replyIdentityKey{}, unique) +} + +func withBackgroundOperation(ctx context.Context) context.Context { + return context.WithValue(ctx, backgroundOperationKey{}, true) +} + +func (a *Adapter) queueAcknowledgement(id filesystem.OperationID) { + a.replyMu.Lock() + if a.ackPending == nil { + a.ackPending = make(map[filesystem.OperationID]struct{}) + } + a.ackPending[id] = struct{}{} + a.replyMu.Unlock() + if a.ackWake != nil { + select { + case a.ackWake <- struct{}{}: + default: + } + } +} + +func (a *Adapter) recordReplyIdentity(ctx context.Context, id filesystem.OperationID) { + unique, ok := ctx.Value(replyIdentityKey{}).(uint64) + if !ok || unique == 0 { + return + } + a.replyMu.Lock() + if a.replyIDs == nil { + a.replyIDs = make(map[uint64][]filesystem.OperationID) + } + a.replyIDs[unique] = append(a.replyIDs[unique], id) + a.replyMu.Unlock() +} + +// replyAttempted is called only after go-fuse has tried writing the reply. +// A failed device write is still a reply attempt; suppressed replies are not. +func (a *Adapter) replyAttempted(unique uint64) { + a.replyMu.Lock() + ids := a.replyIDs[unique] + delete(a.replyIDs, unique) + if a.ackPending == nil { + a.ackPending = make(map[filesystem.OperationID]struct{}) + } + for _, id := range ids { + a.ackPending[id] = struct{}{} + } + a.replyMu.Unlock() + if len(ids) > 0 && a.ackWake != nil { + select { + case a.ackWake <- struct{}{}: + default: + } + } +} + +// replySuppressed is the terminal boundary for a request that go-fuse will +// never answer, such as an interrupted request after the connection died. +func (a *Adapter) replySuppressed(unique uint64) { + a.replyAttempted(unique) +} + +func (a *Adapter) retryAcknowledgements(ctx context.Context) { + ticker := time.NewTicker(ackRetryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-a.ackWake: + } + if a.processAcknowledgementBatch(ctx) { + select { + case a.ackWake <- struct{}{}: + default: + } + } + } +} + +func (a *Adapter) processAcknowledgementBatch(ctx context.Context) bool { + progress := false + for _, id := range a.pendingAcknowledgements() { + attemptCtx, cancel := context.WithTimeout(ctx, ackTimeout) + err := a.acknowledgeReceipt(attemptCtx, id) + cancel() + if err != nil { + slog.Warn("filesystem FUSE operation acknowledgement deferred", "sequence", id.Sequence, "error", err.Error()) + continue + } + a.replyMu.Lock() + delete(a.ackPending, id) + progress = true + a.replyMu.Unlock() + } + a.replyMu.Lock() + remaining := len(a.ackPending) + a.replyMu.Unlock() + return progress && remaining > 0 +} + +func (a *Adapter) pendingAcknowledgements() []filesystem.OperationID { + a.replyMu.Lock() + defer a.replyMu.Unlock() + ids := make([]filesystem.OperationID, 0, len(a.ackPending)) + for id := range a.ackPending { + ids = append(ids, id) + } + slices.SortFunc(ids, func(left, right filesystem.OperationID) int { + return cmp.Compare(left.Sequence, right.Sequence) + }) + if len(ids) == 0 { + return ids + } + start := sort.Search(len(ids), func(i int) bool { return ids[i].Sequence > a.ackCursor }) + selected := make([]filesystem.OperationID, 0, min(len(ids), ackBatchSize)) + for i := 0; i < len(ids) && i < ackBatchSize; i++ { + selected = append(selected, ids[(start+i)%len(ids)]) + } + a.ackCursor = selected[len(selected)-1].Sequence + return selected +} + +func (a *Adapter) acknowledgeReceipt(ctx context.Context, id filesystem.OperationID) error { + acker, ok := a.core.(interface { + AcknowledgeOperation(context.Context, filesystem.OperationID) error + }) + if !ok { + return filesystem.ErrUnsupported + } + a.receiptMu.Lock() + hasReceipt := a.hasReceiptLocked(id) + a.receiptMu.Unlock() + if hasReceipt { + verifier, ok := a.core.(operationReceiptVerifier) + if !ok { + return filesystem.ErrUnsupported + } + if err := verifier.VerifyOperationReceipt(ctx, id); err != nil && !errors.Is(err, filesystem.ErrOperationRetired) { + return errors.Wrap(err, "verify operation before acknowledgement") + } + } + if err := acker.AcknowledgeOperation(ctx, id); err != nil { + return errors.Wrap(err, "acknowledge operation after reply") + } + a.receiptMu.Lock() + for inode, ids := range a.receipts { + delete(ids, id) + if len(ids) == 0 { + delete(a.receipts, inode) + } + } + a.receiptMu.Unlock() + return nil +} + +func (a *Adapter) hasReceiptLocked(id filesystem.OperationID) bool { + for _, ids := range a.receipts { + if _, ok := ids[id]; ok { + return true + } + } + return false +} + +func (a *Adapter) finishStrictSession(ctx context.Context) error { + if !a.strictMode { + return nil + } + closer, ok := a.core.(interface { + CloseOperationSession(context.Context, filesystem.OperationSessionID) error + }) + if !ok { + return filesystem.ErrUnsupported + } + ticker := time.NewTicker(ackDrainInterval) + defer ticker.Stop() + for { + a.replyMu.Lock() + pending := len(a.ackPending) + len(a.replyIDs) + a.replyMu.Unlock() + if pending == 0 { + return errors.Wrap(closer.CloseOperationSession(ctx, a.operationSession), "close strict operation session") + } + select { + case <-ctx.Done(): + return errors.Wrap(ctx.Err(), "wait for strict acknowledgements") + case <-ticker.C: + } + } +} diff --git a/internal/filesystem/fuseadapter/ack_test.go b/internal/filesystem/fuseadapter/ack_test.go new file mode 100644 index 000000000..3fd9779c4 --- /dev/null +++ b/internal/filesystem/fuseadapter/ack_test.go @@ -0,0 +1,218 @@ +package fuseadapter + +import ( + "context" + "syscall" + "testing" + "time" + + "github.com/bootjp/elastickv/internal/filesystem" + "github.com/hanwen/go-fuse/v2/fuse" + "github.com/stretchr/testify/require" +) + +type acknowledgementCore struct { + *receiptCore + acknowledged chan filesystem.OperationID + closed bool +} + +type lostAcknowledgementCore struct { + *receiptCore + retired bool +} + +func (c *lostAcknowledgementCore) VerifyOperationReceipt(context.Context, filesystem.OperationID) error { + if c.retired { + return filesystem.ErrOperationRetired + } + return nil +} + +func (c *lostAcknowledgementCore) AcknowledgeOperation(context.Context, filesystem.OperationID) error { + if !c.retired { + c.retired = true + return context.DeadlineExceeded + } + return nil +} + +func (c *acknowledgementCore) AcknowledgeOperation(_ context.Context, id filesystem.OperationID) error { + c.acknowledged <- id + return nil +} + +func (c *acknowledgementCore) CloseOperationSession(_ context.Context, _ filesystem.OperationSessionID) error { + select { + case <-c.acknowledged: + c.closed = true + return nil + default: + return filesystem.ErrOperationPending + } +} + +func newAcknowledgementCore() *acknowledgementCore { + return &acknowledgementCore{ + receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, + acknowledged: make(chan filesystem.OperationID, 1), + } +} + +func TestAcknowledgementAcceptsRetiredReceipt(t *testing.T) { + core := newAcknowledgementCore() + core.verifyErr = filesystem.ErrOperationRetired + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + require.NoError(t, adapter.acknowledgeReceipt(t.Context(), id)) + require.Empty(t, adapter.receipts) + select { + case got := <-core.acknowledged: + require.Equal(t, id, got) + default: + t.Fatal("operation was not acknowledged") + } + adapter.recordReceipt(7, id) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.True(t, core.fsyncCalled) +} + +func TestAcknowledgementRetriesAfterLostResponse(t *testing.T) { + core := &lostAcknowledgementCore{receiptCore: newAcknowledgementCore().receiptCore} + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + require.ErrorIs(t, adapter.acknowledgeReceipt(t.Context(), id), context.DeadlineExceeded) + require.Contains(t, adapter.receipts[7], id) + require.NoError(t, adapter.acknowledgeReceipt(t.Context(), id)) + require.Empty(t, adapter.receipts) +} + +type blockingAcknowledgementCore struct { + *acknowledgementCore + verifying chan struct{} + release chan struct{} +} + +func (c *blockingAcknowledgementCore) VerifyOperationReceipt(_ context.Context, _ filesystem.OperationID) error { + close(c.verifying) + <-c.release + return nil +} + +func TestAcknowledgementDoesNotHoldReceiptLockDuringVerification(t *testing.T) { + core := &blockingAcknowledgementCore{ + acknowledgementCore: newAcknowledgementCore(), + verifying: make(chan struct{}), + release: make(chan struct{}), + } + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + done := make(chan error, 1) + go func() { done <- adapter.acknowledgeReceipt(t.Context(), id) }() + select { + case <-core.verifying: + case <-time.After(time.Second): + t.Fatal("verification did not start") + } + recorded := make(chan struct{}) + go func() { + adapter.recordReceipt(8, filesystem.OperationID{Filesystem: id.Filesystem, Session: id.Session, Sequence: 2}) + close(recorded) + }() + select { + case <-recorded: + case <-time.After(time.Second): + t.Fatal("receipt lock held during verification") + } + close(core.release) + require.NoError(t, <-done) +} + +func TestFsyncDoesNotHoldReceiptLockDuringVerification(t *testing.T) { + core := &blockingAcknowledgementCore{ + acknowledgementCore: newAcknowledgementCore(), + verifying: make(chan struct{}), + release: make(chan struct{}), + } + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + done := make(chan syscall.Errno, 1) + go func() { done <- adapter.Fsync(t.Context(), 7, 9, false) }() + select { + case <-core.verifying: + case <-time.After(time.Second): + t.Fatal("verification did not start") + } + recorded := make(chan struct{}) + go func() { + adapter.recordReceipt(8, filesystem.OperationID{Filesystem: id.Filesystem, Session: id.Session, Sequence: 2}) + close(recorded) + }() + select { + case <-recorded: + case <-time.After(time.Second): + t.Fatal("receipt lock held during fsync verification") + } + close(core.release) + require.Zero(t, <-done) +} + +func TestStrictCloseDrainsAcknowledgements(t *testing.T) { + core := newAcknowledgementCore() + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0), WithStrictMode(session)) + adapter.queueAcknowledgement(filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1}) + adapter.Close() + require.True(t, core.closed) +} + +func TestAcknowledgementBatchRotatesPastFailures(t *testing.T) { + adapter := New(&fakeCore{}, []byte("client"), WithHandleKeepaliveInterval(0)) + adapter.ackPending = make(map[filesystem.OperationID]struct{}) + for sequence := uint64(1); sequence <= 70; sequence++ { + adapter.ackPending[filesystem.OperationID{ + Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: sequence, + }] = struct{}{} + } + first := adapter.pendingAcknowledgements() + second := adapter.pendingAcknowledgements() + require.Len(t, first, ackBatchSize) + require.EqualValues(t, 1, first[0].Sequence) + require.EqualValues(t, 64, first[len(first)-1].Sequence) + require.EqualValues(t, 65, second[0].Sequence) + require.EqualValues(t, 70, second[5].Sequence) +} + +func TestSuppressedReplyQueuesOperationIdentity(t *testing.T) { + adapter := New(&fakeCore{}, []byte("client"), WithHandleKeepaliveInterval(0)) + fs := NewRawFileSystem(adapter) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReplyIdentity(withReplyIdentity(t.Context(), 42), id) + fs.OnReplySuppressed(42, 0, fuse.EINTR) + require.Empty(t, adapter.replyIDs) + require.Contains(t, adapter.ackPending, id) +} + +func TestStrictSessionWaitsForUnansweredRequest(t *testing.T) { + core := newAcknowledgementCore() + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0), WithStrictMode(session)) + t.Cleanup(adapter.Close) + id := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1} + adapter.recordReplyIdentity(withReplyIdentity(t.Context(), 42), id) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + require.ErrorIs(t, adapter.finishStrictSession(ctx), context.DeadlineExceeded) + require.False(t, core.closed) + adapter.replySuppressed(42) + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.NoError(t, adapter.finishStrictSession(ctx)) + require.True(t, core.closed) +} diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 8277fd1c8..eb275874f 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "sync" + "sync/atomic" "syscall" "time" @@ -18,6 +19,7 @@ const ( defaultHandleKeepaliveInterval = time.Minute defaultHandleKeepaliveTimeout = 5 * time.Second disabledHandleKeepaliveInterval = 0 + leaseRenewalFraction = 4 ) // Core is the filesystem backend surface required by the FUSE adapter. @@ -45,13 +47,40 @@ type Adapter struct { core Core mutations mutationTracker clientID []byte + operationSession filesystem.OperationSessionID + sessionConflict bool + journaledWrites bool + journaledMetadata bool + journaledUnlink bool + journaledRmdir bool + journaledRename bool + journaledCreate bool + journaledMkdir bool + journaledOpen bool + journaledRelease bool + journaledLease bool + strictMode bool + operationSequence atomic.Uint64 + receiptMu sync.Mutex + receipts map[uint64]map[filesystem.OperationID]struct{} + leaseReceiptMu sync.Mutex + leaseReceipts map[openHandleKey][]filesystem.OperationID readdirLimit int keepaliveInterval time.Duration keepaliveTimeout time.Duration keepaliveCancel context.CancelFunc + keepaliveDone chan struct{} closeOnce sync.Once handlesMu sync.Mutex handles map[openHandleKey]struct{} + leaseRefreshed map[openHandleKey]time.Time + leaseRenewalMu sync.Mutex + replyMu sync.Mutex + replyIDs map[uint64][]filesystem.OperationID + ackPending map[filesystem.OperationID]struct{} + ackCursor uint64 + ackWake chan struct{} + ackCancel context.CancelFunc } type Option func(*Adapter) @@ -78,6 +107,7 @@ var errnoMappings = []struct { {filesystem.ErrUnsupported, syscall.EOPNOTSUPP}, {filesystem.ErrStaleHome, syscall.EAGAIN}, {filesystem.ErrContentionLimit, syscall.EBUSY}, + {filesystem.ErrOperationQuota, syscall.ENOSPC}, {store.ErrWriteConflict, syscall.EAGAIN}, } @@ -103,6 +133,114 @@ func WithHandleKeepaliveTimeout(timeout time.Duration) Option { } } +// WithJournaledWrites assigns one durable operation identity to each FUSE +// write request. The caller must register the session before mounting. +func WithJournaledWrites(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledWrites = true + } +} + +// WithJournaledMetadata journals setattr requests, including size changes. +func WithJournaledMetadata(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledMetadata = true + } +} + +// WithJournaledUnlink journals file unlink requests under the same session. +func WithJournaledUnlink(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledUnlink = true + } +} + +// WithJournaledRmdir journals empty-directory removal requests. +func WithJournaledRmdir(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledRmdir = true + } +} + +// WithJournaledRename journals same-directory rename requests. +func WithJournaledRename(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledRename = true + } +} + +// WithJournaledCreate journals file creation and returns its original inode and handle. +func WithJournaledCreate(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledCreate = true + } +} + +// WithJournaledMkdir journals directory creation and its original inode. +func WithJournaledMkdir(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledMkdir = true + } +} + +// WithJournaledOpen journals handle allocation and its original lease. +func WithJournaledOpen(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledOpen = true + } +} + +// WithJournaledRelease records a terminal release for each FUSE handle. +func WithJournaledRelease(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledRelease = true + } +} + +// WithJournaledLeaseRefresh journals foreground and keepalive lease renewals. +func WithJournaledLeaseRefresh(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledLease = true + } +} + +// WithStrictMode routes every supported mutation through the registered +// operation session and enables reply-bound acknowledgement. +func WithStrictMode(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.strictMode = true + a.journaledWrites = true + a.journaledMetadata = true + a.journaledUnlink = true + a.journaledRmdir = true + a.journaledRename = true + a.journaledCreate = true + a.journaledMkdir = true + a.journaledOpen = true + a.journaledRelease = true + a.journaledLease = true + } +} + +func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { + if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { + a.sessionConflict = true + return + } + a.operationSession = session +} + func New(core Core, clientID []byte, opts ...Option) *Adapter { a := &Adapter{ core: core, @@ -117,7 +255,17 @@ func New(core Core, clientID []byte, opts ...Option) *Adapter { if a.keepaliveInterval > disabledHandleKeepaliveInterval { ctx, cancel := context.WithCancel(context.Background()) a.keepaliveCancel = cancel - go a.keepOpenHandlesAlive(ctx) + a.keepaliveDone = make(chan struct{}) + go func() { + defer close(a.keepaliveDone) + a.keepOpenHandlesAlive(ctx) + }() + } + if a.operationSession != (filesystem.OperationSessionID{}) { + ctx, cancel := context.WithCancel(context.Background()) + a.ackCancel = cancel + a.ackWake = make(chan struct{}, 1) + go a.retryAcknowledgements(ctx) } return a } @@ -127,6 +275,27 @@ func (a *Adapter) Close() { if a.keepaliveCancel != nil { a.keepaliveCancel() } + if a.strictMode { + ctx, cancel := context.WithTimeout(context.Background(), ackTimeout) + defer cancel() + var err error + if a.keepaliveDone != nil { + select { + case <-a.keepaliveDone: + case <-ctx.Done(): + err = ctx.Err() + } + } + if err == nil { + err = a.finishStrictSession(ctx) + } + if err != nil { + slog.Warn("filesystem strict session remains open after close", "error", err.Error()) + } + } + if a.ackCancel != nil { + a.ackCancel() + } }) } @@ -174,11 +343,39 @@ func (a *Adapter) SetAttr( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - stat, err = a.core.SetAttr(ctx, inode, mask, attrs) + if a.journaledMetadata { + setter, ok := a.core.(interface { + SetAttrWithOperation(context.Context, filesystem.OperationID, uint64, filesystem.SetAttrMask, filesystem.SetAttr) (filesystem.Stat, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.Stat{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + stat, err = setter.SetAttrWithOperation(ctx, id, inode, mask, attrs) + if err == nil { + a.recordReceipt(inode, id) + } + } else { + stat, err = a.core.SetAttr(ctx, inode, mask, attrs) + } return stat, Errno(err) } +func (a *Adapter) nextOperationID(ctx context.Context) filesystem.OperationID { + id := filesystem.OperationID{Filesystem: a.operationSession.Filesystem, Session: a.operationSession.Session, + Sequence: a.operationSequence.Add(1)} + a.recordReplyIdentity(ctx, id) + return id +} + func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno) { + if a.journaledOpen { + fh, errno := a.openWithOperation(ctx, inode) + if errno == 0 { + a.trackOpenHandle(inode, fh) + } + return fh, errno + } fh, err := a.core.Open(ctx, inode, a.clientID) if err == nil { a.trackOpenHandle(inode, fh) @@ -188,8 +385,32 @@ func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno return fh, Errno(err) } +func (a *Adapter) openWithOperation(ctx context.Context, inode uint64) (uint64, syscall.Errno) { + opener, ok := a.core.(interface { + OpenWithOperation(context.Context, filesystem.OperationID, uint64, []byte) (uint64, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return 0, syscall.EOPNOTSUPP + } + ticket, errno := a.mutations.begin(ctx, inode) + if errno != 0 { + return 0, errno + } + var err error + defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() + id := a.nextOperationID(ctx) + fh, err := opener.OpenWithOperation(ctx, id, inode, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + } + if err != nil && Errno(err) == syscall.EIO { + slog.WarnContext(ctx, "filesystem FUSE open failed", "inode", inode, "error", err.Error()) + } + return fh, Errno(err) +} + func (a *Adapter) Read(ctx context.Context, inode uint64, fh uint64, offset uint64, size uint64) ([]byte, syscall.Errno) { - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { return nil, errno } data, err := a.core.Read(ctx, inode, fh, offset, size) @@ -203,10 +424,27 @@ func (a *Adapter) Write(ctx context.Context, inode uint64, fh uint64, offset uin } var writeErr error defer func() { a.mutations.end(ticket, errors.Is(writeErr, kv.ErrTransactionOutcomeUnknown)) }() - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, true); errno != 0 { return 0, errno } - n, writeErr = a.core.Write(ctx, inode, fh, offset, data) + if len(data) == 0 { + return 0, 0 + } + if a.journaledWrites { + writer, ok := a.core.(interface { + WriteWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) (int, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return 0, syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + n, writeErr = writer.WriteWithOperation(ctx, id, inode, offset, data) + if writeErr == nil { + a.recordReceipt(inode, id) + } + } else { + n, writeErr = a.core.Write(ctx, inode, fh, offset, data) + } if writeErr != nil && Errno(writeErr) == syscall.EIO { slog.WarnContext(ctx, "filesystem FUSE write failed", "inode", inode, "offset", offset, "length", len(data), "error", writeErr.Error()) } @@ -217,7 +455,10 @@ func (a *Adapter) Flush(ctx context.Context, inode uint64, fh uint64) syscall.Er if errno := a.mutations.wait(ctx, inode); errno != 0 { return errno } - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { + return errno + } + if _, errno := a.verifyReceipts(ctx, inode); errno != 0 { return errno } return Errno(a.core.Flush(ctx, inode, fh)) @@ -227,25 +468,61 @@ func (a *Adapter) Fsync(ctx context.Context, inode uint64, fh uint64, datasync b if errno := a.mutations.wait(ctx, inode); errno != 0 { return errno } - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { + return errno + } + receipts, errno := a.verifyReceipts(ctx, inode) + if errno != 0 { + return errno + } + if errno := Errno(a.core.Fsync(ctx, inode, fh, datasync)); errno != 0 { return errno } - return Errno(a.core.Fsync(ctx, inode, fh, datasync)) + a.clearReceipts(inode, receipts) + return 0 } func (a *Adapter) FsyncDirectory(ctx context.Context, inode uint64) syscall.Errno { if errno := a.mutations.wait(ctx, inode); errno != 0 { return errno } - return Errno(a.core.Fsync(ctx, inode, 0, false)) + receipts, errno := a.verifyReceipts(ctx, inode) + if errno != 0 { + return errno + } + if errno := Errno(a.core.Fsync(ctx, inode, 0, false)); errno != 0 { + return errno + } + a.clearReceipts(inode, receipts) + return 0 } func (a *Adapter) Release(ctx context.Context, inode uint64, fh uint64) syscall.Errno { - err := a.core.Release(ctx, inode, fh, a.clientID) + defer a.untrackOpenHandle(inode, fh) + var err error + if a.journaledRelease { + ticket, errno := a.mutations.begin(ctx, inode) + if errno != 0 { + return errno + } + defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() + releaser, ok := a.core.(interface { + ReleaseWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + err = releaser.ReleaseWithOperation(ctx, id, inode, fh, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + } + } else { + err = a.core.Release(ctx, inode, fh, a.clientID) + } if err != nil && Errno(err) == syscall.EIO { slog.WarnContext(ctx, "filesystem FUSE release uncertain", "inode", inode, "fh", fh, "error", err.Error()) } - a.untrackOpenHandle(inode, fh) return Errno(err) } @@ -262,7 +539,22 @@ func (a *Adapter) Create( var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() opts.ClientID = append([]byte(nil), a.clientID...) - result, err = a.core.Create(ctx, parent, name, opts) + if a.journaledCreate { + creator, ok := a.core.(interface { + CreateWithOperation(context.Context, filesystem.OperationID, uint64, []byte, filesystem.CreateOptions) (filesystem.CreateResult, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.CreateResult{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + result, err = creator.CreateWithOperation(ctx, id, parent, name, opts) + if err == nil { + a.recordReceipt(parent, id) + a.recordReceipt(result.Inode, id) + } + } else { + result, err = a.core.Create(ctx, parent, name, opts) + } if err == nil { a.trackOpenHandle(result.Inode, result.FH) } @@ -282,7 +574,22 @@ func (a *Adapter) Mknod( var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() opts.ClientID = nil - result, err = a.core.Create(ctx, parent, name, opts) + if a.journaledCreate { + creator, ok := a.core.(interface { + CreateWithOperation(context.Context, filesystem.OperationID, uint64, []byte, filesystem.CreateOptions) (filesystem.CreateResult, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.CreateResult{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + result, err = creator.CreateWithOperation(ctx, id, parent, name, opts) + if err == nil { + a.recordReceipt(parent, id) + a.recordReceipt(result.Inode, id) + } + } else { + result, err = a.core.Create(ctx, parent, name, opts) + } return result, Errno(err) } @@ -298,7 +605,21 @@ func (a *Adapter) Mkdir( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - result, err = a.core.Mkdir(ctx, parent, name, opts) + if a.journaledMkdir { + creator, ok := a.core.(interface { + MkdirWithOperation(context.Context, filesystem.OperationID, uint64, []byte, filesystem.CreateOptions) (filesystem.CreateResult, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.CreateResult{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + result, err = creator.MkdirWithOperation(ctx, id, parent, name, opts) + if err == nil { + a.recordReceipt(parent, id) + } + } else { + result, err = a.core.Mkdir(ctx, parent, name, opts) + } return result, Errno(err) } @@ -309,7 +630,21 @@ func (a *Adapter) Unlink(ctx context.Context, parent uint64, name []byte) (errno } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - err = a.core.Unlink(ctx, parent, name) + if a.journaledUnlink { + unlinker, ok := a.core.(interface { + UnlinkWithOperation(context.Context, filesystem.OperationID, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + err = unlinker.UnlinkWithOperation(ctx, id, parent, name) + if err == nil { + a.recordReceipt(parent, id) + } + } else { + err = a.core.Unlink(ctx, parent, name) + } return Errno(err) } @@ -320,7 +655,21 @@ func (a *Adapter) Rmdir(ctx context.Context, parent uint64, name []byte) (errno } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - err = a.core.Rmdir(ctx, parent, name) + if a.journaledRmdir { + remover, ok := a.core.(interface { + RmdirWithOperation(context.Context, filesystem.OperationID, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + err = remover.RmdirWithOperation(ctx, id, parent, name) + if err == nil { + a.recordReceipt(parent, id) + } + } else { + err = a.core.Rmdir(ctx, parent, name) + } return Errno(err) } @@ -337,7 +686,21 @@ func (a *Adapter) Rename( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - err = a.core.Rename(ctx, oldParent, oldName, newParent, newName) + if a.journaledRename { + renamer, ok := a.core.(interface { + RenameWithOperation(context.Context, filesystem.OperationID, uint64, []byte, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID(ctx) + err = renamer.RenameWithOperation(ctx, id, oldParent, oldName, newParent, newName) + if err == nil { + a.recordReceipt(oldParent, id) + } + } else { + err = a.core.Rename(ctx, oldParent, oldName, newParent, newName) + } return Errno(err) } @@ -351,7 +714,100 @@ func (a *Adapter) StatFS(ctx context.Context, inode uint64) (filesystem.StatFS, return stat, Errno(err) } -func (a *Adapter) refreshOpenHandleLease(ctx context.Context, inode uint64, fh uint64) syscall.Errno { +func (a *Adapter) refreshOpenHandleLease(ctx context.Context, inode uint64, fh uint64, alreadyTracked bool) syscall.Errno { + if !a.journaledLease { + return a.refreshLegacyLease(ctx, inode, fh) + } + var err error + if !alreadyTracked { + ticket, errno := a.mutations.begin(ctx, inode) + if errno != 0 { + return errno + } + defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() + } + err = a.refreshJournaledLease(ctx, inode, fh) + if alreadyTracked && errors.Is(err, kv.ErrTransactionOutcomeUnknown) { + a.mutations.markUnknown(inode) + } + if err != nil && Errno(err) == syscall.EIO { + slog.WarnContext(ctx, "filesystem FUSE lease refresh failed", "inode", inode, "fh", fh, "error", err.Error()) + } + return Errno(err) +} + +func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh uint64) error { + if verifier, recent := a.recentLeaseVerifier(inode, fh); recent { + return errors.Wrap(verifier.ValidateOpenHandleLease(ctx, inode, fh, a.clientID), "validate open handle lease") + } + a.leaseRenewalMu.Lock() + defer a.leaseRenewalMu.Unlock() + if verifier, recent := a.recentLeaseVerifier(inode, fh); recent { + return errors.Wrap(verifier.ValidateOpenHandleLease(ctx, inode, fh, a.clientID), "validate open handle lease") + } + refresher, ok := a.core.(interface { + RefreshOpenHandleLeaseWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.ErrUnsupported + } + id := a.nextOperationID(ctx) + err := refresher.RefreshOpenHandleLeaseWithOperation(ctx, id, inode, fh, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + a.retainLatestLeaseReceipt(ctx, inode, fh, id) + a.markLeaseRefreshed(inode, fh) + } + // Keepalive renewals have no kernel reply. Completion of the call is their + // acknowledgement boundary; the worker resolves uncertain outcomes under + // the journal's admission and publication fences before retirement. + if ctx.Value(backgroundOperationKey{}) == true { + a.queueAcknowledgement(id) + } + return errors.Wrap(err, "refresh journaled open handle lease") +} + +type leaseReadVerifier interface { + ValidateOpenHandleLease(context.Context, uint64, uint64, []byte) error + OpenHandleLeaseTTL() time.Duration +} + +func (a *Adapter) recentLeaseVerifier(inode, fh uint64) (leaseReadVerifier, bool) { + verifier, ok := a.core.(leaseReadVerifier) + if !ok { + return nil, false + } + a.handlesMu.Lock() + last, known := a.leaseRefreshed[openHandleKey{inode: inode, fh: fh}] + a.handlesMu.Unlock() + if !known { + return nil, false + } + ttl := verifier.OpenHandleLeaseTTL() + if ttl == 0 || time.Since(last) < leaseRefreshWindow(ttl) { + return verifier, true + } + return nil, false +} + +func leaseRefreshWindow(ttl time.Duration) time.Duration { + window := min(ttl/leaseRenewalFraction, time.Minute) + if window <= 0 { + return time.Nanosecond + } + return window +} + +func (a *Adapter) markLeaseRefreshed(inode, fh uint64) { + a.handlesMu.Lock() + defer a.handlesMu.Unlock() + if a.leaseRefreshed == nil { + a.leaseRefreshed = make(map[openHandleKey]time.Time) + } + a.leaseRefreshed[openHandleKey{inode: inode, fh: fh}] = time.Now() +} + +func (a *Adapter) refreshLegacyLease(ctx context.Context, inode uint64, fh uint64) syscall.Errno { err := a.core.RefreshOpenHandleLease(ctx, inode, fh, a.clientID) if err != nil && Errno(err) == syscall.EIO { slog.WarnContext(ctx, "filesystem FUSE lease refresh failed", "inode", inode, "fh", fh, "error", err.Error()) @@ -369,15 +825,24 @@ func (a *Adapter) trackOpenHandle(inode uint64, fh uint64) { a.handles = make(map[openHandleKey]struct{}) } a.handles[openHandleKey{inode: inode, fh: fh}] = struct{}{} + if a.leaseRefreshed == nil { + a.leaseRefreshed = make(map[openHandleKey]time.Time) + } + a.leaseRefreshed[openHandleKey{inode: inode, fh: fh}] = time.Now() } func (a *Adapter) untrackOpenHandle(inode uint64, fh uint64) { if fh == 0 { return } + key := openHandleKey{inode: inode, fh: fh} a.handlesMu.Lock() - defer a.handlesMu.Unlock() - delete(a.handles, openHandleKey{inode: inode, fh: fh}) + delete(a.handles, key) + delete(a.leaseRefreshed, key) + a.handlesMu.Unlock() + a.leaseReceiptMu.Lock() + delete(a.leaseReceipts, key) + a.leaseReceiptMu.Unlock() } func (a *Adapter) openHandleSnapshot() []openHandleKey { @@ -408,12 +873,12 @@ func (a *Adapter) keepOpenHandlesAlive(ctx context.Context) { func (a *Adapter) refreshTrackedOpenHandles(ctx context.Context) { for _, handle := range a.openHandleSnapshot() { - handleCtx, cancel := context.WithTimeout(ctx, a.keepaliveTimeout) - err := a.core.RefreshOpenHandleLease(handleCtx, handle.inode, handle.fh, a.clientID) + handleCtx, cancel := context.WithTimeout(withBackgroundOperation(ctx), a.keepaliveTimeout) + errno := a.refreshOpenHandleLease(handleCtx, handle.inode, handle.fh, false) cancel() switch { - case err == nil: - case errors.Is(err, filesystem.ErrNotFound): + case errno == 0: + case errno == syscall.ENOENT: a.untrackOpenHandle(handle.inode, handle.fh) case ctx.Err() != nil: return @@ -421,7 +886,7 @@ func (a *Adapter) refreshTrackedOpenHandles(ctx context.Context) { slog.WarnContext(ctx, "filesystem open handle lease refresh failed", "inode", handle.inode, "fh", handle.fh, - "err", err, + "errno", errno, ) } } diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 23cbda08c..132557912 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bootjp/elastickv/internal/filesystem" + "github.com/bootjp/elastickv/kv" "github.com/stretchr/testify/require" ) @@ -54,6 +55,320 @@ func TestAdapterWriteRefreshesLeaseThenForwards(t *testing.T) { require.Equal(t, []byte("data"), core.writeData) } +type journalWriteCore struct { + *fakeCore + ids []filesystem.OperationID +} + +func (c *journalWriteCore) WriteWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, data []byte) (int, error) { + c.ids = append(c.ids, id) + return len(data), nil +} + +type journalMetadataCore struct { + *journalWriteCore + metadataIDs []filesystem.OperationID +} + +type receiptCore struct { + *journalMetadataCore + verified []filesystem.OperationID + verifyErr error +} + +type journalUnlinkCore struct { + *receiptCore + unlinkIDs []filesystem.OperationID +} + +type journalLifecycleCore struct { + *receiptCore + createID filesystem.OperationID + openID filesystem.OperationID + openErr error + releaseID filesystem.OperationID +} + +type journalLeaseCore struct { + *receiptCore + leaseIDs []filesystem.OperationID + leaseErr error +} + +type validatingLeaseCore struct { + *journalLeaseCore + ttl time.Duration + validationErr error + validations int +} + +func (c *validatingLeaseCore) OpenHandleLeaseTTL() time.Duration { return c.ttl } + +func (c *validatingLeaseCore) ValidateOpenHandleLease(context.Context, uint64, uint64, []byte) error { + c.validations++ + return c.validationErr +} + +func TestAdapterReadCoalescesJournaledLeaseRefresh(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &validatingLeaseCore{ttl: 5 * time.Minute, journalLeaseCore: &journalLeaseCore{ + receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, + }} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + for range 100 { + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Zero(t, errno) + } + require.Len(t, core.leaseIDs, 1) + require.Equal(t, 99, core.validations) + adapter.handlesMu.Lock() + adapter.leaseRefreshed[openHandleKey{inode: 7, fh: 9}] = time.Now().Add(-2 * time.Minute) + adapter.handlesMu.Unlock() + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Zero(t, errno) + require.Len(t, core.leaseIDs, 2) + require.Equal(t, []filesystem.OperationID{core.leaseIDs[0]}, core.verified) + adapter.receiptMu.Lock() + require.Len(t, adapter.receipts[7], 1) + adapter.receiptMu.Unlock() + core.validationErr = filesystem.ErrNotFound + _, errno = adapter.Read(t.Context(), 7, 9, 0, 1) + require.Equal(t, syscall.ENOENT, errno) + require.Len(t, core.leaseIDs, 2) +} + +func (c *journalLeaseCore) RefreshOpenHandleLeaseWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, _ []byte) error { + c.leaseIDs = append(c.leaseIDs, id) + return c.leaseErr +} + +func TestAdapterJournaledLeaseRefreshPrecedesFsync(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Zero(t, errno) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.ElementsMatch(t, core.leaseIDs, core.verified) + require.Len(t, core.leaseIDs, 2) + require.True(t, core.fsyncCalled) +} + +func TestAdapterUnknownLeaseRefreshStopsLaterFsync(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, leaseErr: kv.ErrTransactionOutcomeUnknown} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Equal(t, syscall.EIO, errno) + require.Equal(t, syscall.EIO, adapter.Fsync(t.Context(), 7, 9, false)) + require.False(t, core.fsyncCalled) +} + +func TestAdapterKeepaliveUsesJournaledLeaseRefresh(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + adapter.trackOpenHandle(7, 9) + adapter.refreshTrackedOpenHandles(t.Context()) + require.Len(t, core.leaseIDs, 1) + adapter.replyMu.Lock() + _, queued := adapter.ackPending[core.leaseIDs[0]] + adapter.replyMu.Unlock() + require.True(t, queued) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.ElementsMatch(t, core.leaseIDs, core.verified) +} + +func TestAdapterKeepaliveQueuesUnknownLeaseForResolution(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, leaseErr: kv.ErrTransactionOutcomeUnknown} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + adapter.trackOpenHandle(7, 9) + adapter.refreshTrackedOpenHandles(t.Context()) + require.Len(t, core.leaseIDs, 1) + adapter.replyMu.Lock() + _, queued := adapter.ackPending[core.leaseIDs[0]] + adapter.replyMu.Unlock() + require.True(t, queued) +} + +func (c *journalLifecycleCore) CreateWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte, _ filesystem.CreateOptions) (filesystem.CreateResult, error) { + c.createID = id + return filesystem.CreateResult{Inode: 7, FH: 9, Stat: filesystem.Stat{Inode: 7}}, nil +} + +func (c *journalLifecycleCore) OpenWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte) (uint64, error) { + c.openID = id + return 10, c.openErr +} + +func TestAdapterUnknownOpenStopsLaterFsync(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLifecycleCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, openErr: kv.ErrTransactionOutcomeUnknown} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledOpen(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Open(t.Context(), 7) + require.Equal(t, syscall.EIO, errno) + require.Equal(t, syscall.EIO, adapter.Fsync(t.Context(), 7, 9, false)) + require.False(t, core.fsyncCalled) +} + +func (c *journalLifecycleCore) ReleaseWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, _ []byte) error { + c.releaseID = id + return nil +} + +func TestAdapterFsyncVerifiesCreateOpenAndReleaseReceipts(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLifecycleCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledCreate(session), WithJournaledOpen(session), WithJournaledRelease(session)) + t.Cleanup(adapter.Close) + result, errno := adapter.Create(t.Context(), filesystem.RootInode, []byte("file"), filesystem.CreateOptions{Mode: 0o600}) + require.Zero(t, errno) + fh, errno := adapter.Open(t.Context(), result.Inode) + require.Zero(t, errno) + require.Zero(t, adapter.Release(t.Context(), result.Inode, fh)) + require.Zero(t, adapter.Fsync(t.Context(), result.Inode, result.FH, false)) + require.ElementsMatch(t, []filesystem.OperationID{core.createID, core.openID, core.releaseID}, core.verified) + core.verified = nil + require.Zero(t, adapter.FsyncDirectory(t.Context(), filesystem.RootInode)) + require.Equal(t, []filesystem.OperationID{core.createID}, core.verified) +} + +func (c *journalUnlinkCore) UnlinkWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte) error { + c.unlinkIDs = append(c.unlinkIDs, id) + return nil +} + +func TestAdapterDirectoryFsyncVerifiesUnlinkReceipt(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalUnlinkCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledUnlink(session)) + t.Cleanup(adapter.Close) + require.Zero(t, adapter.Unlink(t.Context(), filesystem.RootInode, []byte("file"))) + require.Zero(t, adapter.FsyncDirectory(t.Context(), filesystem.RootInode)) + require.Equal(t, core.unlinkIDs, core.verified) + require.True(t, core.fsyncCalled) + require.Zero(t, core.unlinkParent) +} + +func (c *receiptCore) VerifyOperationReceipt(_ context.Context, id filesystem.OperationID) error { + c.verified = append(c.verified, id) + return c.verifyErr +} + +func TestAdapterFsyncVerifiesJournalReceipts(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledWrites(session), WithJournaledMetadata(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + _, errno = adapter.SetAttr(t.Context(), 7, filesystem.SetAttrMask{Mode: true}, filesystem.SetAttr{Mode: 0o600}) + require.Zero(t, errno) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.ElementsMatch(t, []filesystem.OperationID{core.ids[0], core.metadataIDs[0]}, core.verified) + require.True(t, core.fsyncCalled) + core.verified = nil + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.Empty(t, core.verified) +} + +func TestAdapterFsyncFailsClosedOnUnverifiedReceipt(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }, verifyErr: filesystem.ErrOperationPending} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledWrites(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + require.Equal(t, syscall.EIO, adapter.Fsync(t.Context(), 7, 9, false)) + require.False(t, core.fsyncCalled) + require.Equal(t, syscall.EIO, adapter.Flush(t.Context(), 7, 9)) + require.False(t, core.flushCalled) +} + +func (c *journalMetadataCore) SetAttrWithOperation(_ context.Context, id filesystem.OperationID, inode uint64, _ filesystem.SetAttrMask, _ filesystem.SetAttr) (filesystem.Stat, error) { + c.metadataIDs = append(c.metadataIDs, id) + return filesystem.Stat{Inode: inode}, nil +} + +func TestAdapterJournaledMetadataSharesWriteSequence(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalMetadataCore{journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledWrites(session), WithJournaledMetadata(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.SetAttr(t.Context(), 7, filesystem.SetAttrMask{Mode: true}, filesystem.SetAttr{Mode: 0o600}) + require.Zero(t, errno) + _, errno = adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + require.EqualValues(t, 1, core.metadataIDs[0].Sequence) + require.EqualValues(t, 2, core.ids[0].Sequence) + require.Zero(t, core.setAttrInode) +} + +func TestAdapterRejectsDifferentJournalSessions(t *testing.T) { + first := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + second := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{3}} + core := &journalMetadataCore{journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledWrites(first), WithJournaledMetadata(second)) + t.Cleanup(adapter.Close) + _, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Equal(t, syscall.EOPNOTSUPP, errno) + _, errno = adapter.SetAttr(t.Context(), 7, filesystem.SetAttrMask{Mode: true}, filesystem.SetAttr{Mode: 0o600}) + require.Equal(t, syscall.EOPNOTSUPP, errno) + require.Empty(t, core.ids) + require.Empty(t, core.metadataIDs) +} + +func TestAdapterJournaledWritesAllocateStableSessionSequence(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalWriteCore{fakeCore: &fakeCore{}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledWrites(session)) + t.Cleanup(adapter.Close) + n, errno := adapter.Write(t.Context(), 7, 9, 0, nil) + require.Zero(t, errno) + require.Zero(t, n) + for range 2 { + n, errno = adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + require.Equal(t, 1, n) + } + require.False(t, core.writeCalled) + require.Equal(t, []filesystem.OperationID{ + {Filesystem: session.Filesystem, Session: session.Session, Sequence: 1}, + {Filesystem: session.Filesystem, Session: session.Session, Sequence: 2}, + }, core.ids) +} + func TestAdapterWriteStopsWhenLeaseRefreshFails(t *testing.T) { ctx := context.Background() core := &fakeCore{refreshErr: filesystem.ErrStaleHome} diff --git a/internal/filesystem/fuseadapter/adapter_test.go b/internal/filesystem/fuseadapter/adapter_test.go index a132f8c2d..3e1a94f00 100644 --- a/internal/filesystem/fuseadapter/adapter_test.go +++ b/internal/filesystem/fuseadapter/adapter_test.go @@ -33,6 +33,7 @@ func TestErrnoMapsFilesystemErrors(t *testing.T) { {name: "unsupported", err: filesystem.ErrUnsupported, want: syscall.EOPNOTSUPP}, {name: "stale home", err: filesystem.ErrStaleHome, want: syscall.EAGAIN}, {name: "contention limit", err: filesystem.ErrContentionLimit, want: syscall.EBUSY}, + {name: "operation journal quota", err: filesystem.ErrOperationQuota, want: syscall.ENOSPC}, {name: "write conflict", err: cerrors.Wrap(store.ErrWriteConflict, "txn"), want: syscall.EAGAIN}, { name: "legacy textual forwarded conflict is uncertain", diff --git a/internal/filesystem/fuseadapter/barrier.go b/internal/filesystem/fuseadapter/barrier.go index f5465c92b..a86284aab 100644 --- a/internal/filesystem/fuseadapter/barrier.go +++ b/internal/filesystem/fuseadapter/barrier.go @@ -111,6 +111,15 @@ func (m *mutationTracker) end(ticket *mutationTicket, unknown bool) { close(ticket.done) } +func (m *mutationTracker) markUnknown(key uint64) { + m.mu.Lock() + defer m.mu.Unlock() + if m.sticky == nil { + m.sticky = make(map[uint64]syscall.Errno) + } + m.sticky[key] = syscall.EIO +} + func (m *mutationTracker) wait(ctx context.Context, key uint64) syscall.Errno { waitCtx, cancel := context.WithTimeout(ctx, mutationQueueWaitBudget) defer cancel() diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index e250df58c..f09f9aec4 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -71,8 +71,12 @@ func newMemoryServiceForMount(t *testing.T) *filesystem.Service { } func newMemoryServiceForMountWithChunkSize(t *testing.T, chunkSize uint64) *filesystem.Service { + return newMemoryServiceForMountWithOptions(t, filesystem.WithChunkSize(chunkSize)) +} + +func newMemoryServiceForMountWithOptions(t *testing.T, opts ...filesystem.Option) *filesystem.Service { t.Helper() - svc, err := fstest.NewMemoryService(filesystem.WithChunkSize(chunkSize)) + svc, err := fstest.NewMemoryService(opts...) require.NoError(t, err) info, err := os.Stat(t.TempDir()) require.NoError(t, err) @@ -90,8 +94,12 @@ func mountMemoryService(t *testing.T, svc fuseadapter.Core, clientID string) str } func mountMemoryServiceAt(t *testing.T, svc fuseadapter.Core, clientID, mountPoint string) *e2eMount { + return mountMemoryServiceAtWithOptions(t, svc, clientID, mountPoint) +} + +func mountMemoryServiceAtWithOptions(t *testing.T, svc fuseadapter.Core, clientID, mountPoint string, adapterOptions ...fuseadapter.Option) *e2eMount { t.Helper() - adapter := fuseadapter.New(svc, []byte(clientID)) + adapter := fuseadapter.New(svc, []byte(clientID), adapterOptions...) server, err := fuseadapter.Mount(mountPoint, adapter, &fuse.MountOptions{DirectMount: os.Geteuid() == 0}) if err != nil { adapter.Close() @@ -384,3 +392,269 @@ func TestKernelMountStatfsReportsNameLimit(t *testing.T) { require.NotZero(t, stat.Bsize) require.EqualValues(t, 255, stat.Namelen) } + +func requireOperationRetired(t *testing.T, journal *filesystem.OperationJournal, id filesystem.OperationID) { + t.Helper() + require.Eventually(t, func() bool { + _, err := journal.Resolve(t.Context(), id) + return errors.Is(err, filesystem.ErrOperationRetired) + }, 5*time.Second, 10*time.Millisecond) +} + +func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-write") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-write", mountPoint, fuseadapter.WithJournaledWrites(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("journaled"), 0o600)) + f, err := os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("journaled"), data) + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) +} + +func TestKernelMountStrictModeMutationsAndReplyAcknowledgement(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-strict") + require.NoError(t, err) + mountPoint := t.TempDir() + mount := mountMemoryServiceAtWithOptions(t, svc, "e2e-strict", mountPoint, fuseadapter.WithStrictMode(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("strict"), 0o600)) + require.NoError(t, os.Truncate(path, 3)) + f, err := os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("str"), data) + require.NoError(t, os.Mkdir(filepath.Join(mountPoint, "directory"), 0o700)) + err = os.Rename(path, filepath.Join(mountPoint, "directory", "cross")) + require.ErrorIs(t, err, syscall.EXDEV) + require.NoError(t, os.Rename(path, filepath.Join(mountPoint, "renamed"))) + require.NoError(t, os.Remove(filepath.Join(mountPoint, "renamed"))) + directory, err := os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, directory.Sync()) + require.NoError(t, directory.Close()) + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) + require.NoError(t, mount.unmount()) + _, err = journal.RegisterSession(t.Context(), filesystemID, "e2e-strict") + require.NoError(t, err) +} + +func TestKernelMountStrictModeReclaimsJournalQuota(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-strict-quota") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-strict-quota", mountPoint, fuseadapter.WithStrictMode(session)) + file, err := os.Create(filepath.Join(mountPoint, "file")) + require.NoError(t, err) + for range 4200 { + _, err = file.Write([]byte("x")) + require.NoError(t, err) + } + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + stat, err := os.Stat(filepath.Join(mountPoint, "file")) + require.NoError(t, err) + require.EqualValues(t, 4200, stat.Size()) + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) +} + +func TestKernelMountJournaledCreateAndMkdir(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-create") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-create", mountPoint, + fuseadapter.WithJournaledCreate(session), fuseadapter.WithJournaledMkdir(session), + fuseadapter.WithJournaledOpen(session), fuseadapter.WithJournaledRelease(session)) + filePath := filepath.Join(mountPoint, "file") + file, err := os.Create(filePath) + require.NoError(t, err) + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + file, err = os.Open(filePath) + require.NoError(t, err) + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + directoryPath := filepath.Join(mountPoint, "dir") + require.NoError(t, os.Mkdir(directoryPath, 0o700)) + directory, err := os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, directory.Sync()) + require.NoError(t, directory.Close()) + for sequence := uint64(1); sequence <= 5; sequence++ { + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: sequence, + }) + } +} + +func TestKernelMountJournaledLeaseRefreshAndFsync(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMountWithOptions(t, filesystem.WithChunkSize(e2eChunkSize), + filesystem.WithOpenHandleLeaseTTL(2*time.Second)) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-lease") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-lease", mountPoint, + fuseadapter.WithJournaledLeaseRefresh(session)) + file, err := os.Create(filepath.Join(mountPoint, "file")) + require.NoError(t, err) + time.Sleep(600 * time.Millisecond) + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) +} + +func TestKernelMountJournaledMetadataChange(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-metadata") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-metadata", mountPoint, + fuseadapter.WithJournaledMetadata(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("data"), 0o600)) + require.NoError(t, os.Chmod(path, 0o640)) + f, err := os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + stat, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o640), stat.Mode().Perm()) + require.NoError(t, os.Truncate(path, 2)) + f, err = os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + stat, err = os.Stat(path) + require.NoError(t, err) + require.EqualValues(t, 2, stat.Size()) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("da"), data) +} + +func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-unlink") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-unlink", mountPoint, + fuseadapter.WithJournaledUnlink(session), fuseadapter.WithJournaledRmdir(session), + fuseadapter.WithJournaledRename(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) + require.NoError(t, os.Remove(path)) + dir, err := os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, dir.Sync()) + require.NoError(t, dir.Close()) + require.NoError(t, os.WriteFile(path, []byte("new"), 0o600)) + id := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1} + requireOperationRetired(t, journal, id) + require.ErrorIs(t, svc.UnlinkWithOperation(t.Context(), id, filesystem.RootInode, []byte("file")), filesystem.ErrOperationRetired) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("new"), data) + dirPath := filepath.Join(mountPoint, "dir") + require.NoError(t, os.Mkdir(dirPath, 0o700)) + require.NoError(t, os.Remove(dirPath)) + dir, err = os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, dir.Sync()) + require.NoError(t, dir.Close()) + require.NoError(t, os.Mkdir(dirPath, 0o700)) + directoryID := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 2} + requireOperationRetired(t, journal, directoryID) + require.ErrorIs(t, svc.RmdirWithOperation(t.Context(), directoryID, filesystem.RootInode, []byte("dir")), filesystem.ErrOperationRetired) + stat, err := os.Stat(dirPath) + require.NoError(t, err) + require.True(t, stat.IsDir()) + source := filepath.Join(mountPoint, "source") + target := filepath.Join(mountPoint, "target") + require.NoError(t, os.WriteFile(source, []byte("old"), 0o600)) + require.NoError(t, os.Rename(source, target)) + dir, err = os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, dir.Sync()) + require.NoError(t, dir.Close()) + require.NoError(t, os.WriteFile(source, []byte("new"), 0o600)) + renameID := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 3} + requireOperationRetired(t, journal, renameID) + require.ErrorIs(t, svc.RenameWithOperation(t.Context(), renameID, filesystem.RootInode, []byte("source"), filesystem.RootInode, []byte("target")), filesystem.ErrOperationRetired) + sourceData, err := os.ReadFile(source) + require.NoError(t, err) + require.Equal(t, []byte("new"), sourceData) + targetData, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, []byte("old"), targetData) +} diff --git a/internal/filesystem/fuseadapter/receipts.go b/internal/filesystem/fuseadapter/receipts.go new file mode 100644 index 000000000..cf363b5b0 --- /dev/null +++ b/internal/filesystem/fuseadapter/receipts.go @@ -0,0 +1,84 @@ +package fuseadapter + +import ( + "context" + "log/slog" + "syscall" + + "github.com/bootjp/elastickv/internal/filesystem" + "github.com/cockroachdb/errors" +) + +type operationReceiptVerifier interface { + VerifyOperationReceipt(context.Context, filesystem.OperationID) error +} + +func (a *Adapter) recordReceipt(inode uint64, id filesystem.OperationID) { + a.receiptMu.Lock() + defer a.receiptMu.Unlock() + if a.receipts == nil { + a.receipts = make(map[uint64]map[filesystem.OperationID]struct{}) + } + if a.receipts[inode] == nil { + a.receipts[inode] = make(map[filesystem.OperationID]struct{}) + } + a.receipts[inode][id] = struct{}{} +} + +// retainLatestLeaseReceipt drops older lease receipts only after their +// participant proofs have been checked. Failed checks leave them for fsync. +func (a *Adapter) retainLatestLeaseReceipt(ctx context.Context, inode, fh uint64, id filesystem.OperationID) { + a.leaseReceiptMu.Lock() + defer a.leaseReceiptMu.Unlock() + key := openHandleKey{inode: inode, fh: fh} + if a.leaseReceipts == nil { + a.leaseReceipts = make(map[openHandleKey][]filesystem.OperationID) + } + previous := a.leaseReceipts[key] + a.leaseReceipts[key] = append(previous, id) + verifier, ok := a.core.(operationReceiptVerifier) + if !ok { + return + } + for _, old := range previous { + if err := verifier.VerifyOperationReceipt(ctx, old); err != nil && !errors.Is(err, filesystem.ErrOperationRetired) { + slog.WarnContext(ctx, "filesystem lease receipt verification failed", "inode", inode, "fh", fh, "error", err.Error()) + return + } + } + a.clearReceipts(inode, previous) + a.leaseReceipts[key] = []filesystem.OperationID{id} +} + +func (a *Adapter) verifyReceipts(ctx context.Context, inode uint64) ([]filesystem.OperationID, syscall.Errno) { + a.receiptMu.Lock() + receipts := make([]filesystem.OperationID, 0, len(a.receipts[inode])) + for id := range a.receipts[inode] { + receipts = append(receipts, id) + } + a.receiptMu.Unlock() + if len(receipts) == 0 { + return receipts, 0 + } + verifier, ok := a.core.(operationReceiptVerifier) + if !ok { + return nil, syscall.EOPNOTSUPP + } + for _, id := range receipts { + if err := verifier.VerifyOperationReceipt(ctx, id); err != nil && !errors.Is(err, filesystem.ErrOperationRetired) { + return nil, Errno(err) + } + } + return receipts, 0 +} + +func (a *Adapter) clearReceipts(inode uint64, receipts []filesystem.OperationID) { + a.receiptMu.Lock() + defer a.receiptMu.Unlock() + for _, id := range receipts { + delete(a.receipts[inode], id) + } + if len(a.receipts[inode]) == 0 { + delete(a.receipts, inode) + } +} diff --git a/internal/filesystem/fuseadapter/server.go b/internal/filesystem/fuseadapter/server.go index 45d838171..90075de68 100644 --- a/internal/filesystem/fuseadapter/server.go +++ b/internal/filesystem/fuseadapter/server.go @@ -89,6 +89,17 @@ func (r *RawFileSystem) String() string { } +// OnReplyAttempt is invoked by the local go-fuse bridge after its device write. +func (r *RawFileSystem) OnReplyAttempt(unique uint64, _ uint32, _ fuse.Status, _ fuse.Status) { + r.adapter.replyAttempted(unique) +} + +// OnReplySuppressed retires a request after go-fuse has decided that no FUSE +// reply can be sent, including an interrupted request on a dead connection. +func (r *RawFileSystem) OnReplySuppressed(unique uint64, _ uint32, _ fuse.Status) { + r.adapter.replySuppressed(unique) +} + // toStatus converts an adapter errno into a FUSE status. Errno values // outside the int32 range of fuse.Status cannot be a real errno, so they // map to EIO instead of being truncated. @@ -123,7 +134,7 @@ func (r *RawFileSystem) GetAttr(cancel <-chan struct{}, input *fuse.GetAttrIn, o } func (r *RawFileSystem) SetAttr(cancel <-chan struct{}, input *fuse.SetAttrIn, out *fuse.AttrOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() mask, attrs := fuseSetAttr(input) stat, errno := r.adapter.SetAttr(ctx, input.NodeId, mask, attrs) @@ -139,7 +150,7 @@ func (r *RawFileSystem) Mknod(cancel <-chan struct{}, input *fuse.MknodIn, name if input.Mode&syscall.S_IFMT != syscall.S_IFREG { return fuse.Status(syscall.EOPNOTSUPP) } - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() result, errno := r.adapter.Mknod(ctx, input.NodeId, []byte(name), filesystem.CreateOptions{ Mode: input.Mode & permissionModeMask, @@ -154,7 +165,7 @@ func (r *RawFileSystem) Mknod(cancel <-chan struct{}, input *fuse.MknodIn, name } func (r *RawFileSystem) Mkdir(cancel <-chan struct{}, input *fuse.MkdirIn, name string, out *fuse.EntryOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() result, errno := r.adapter.Mkdir(ctx, input.NodeId, []byte(name), filesystem.CreateOptions{ Mode: input.Mode & permissionModeMask, @@ -169,13 +180,13 @@ func (r *RawFileSystem) Mkdir(cancel <-chan struct{}, input *fuse.MkdirIn, name } func (r *RawFileSystem) Unlink(cancel <-chan struct{}, header *fuse.InHeader, name string) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, header.Unique) defer done() return toStatus(r.adapter.Unlink(ctx, header.NodeId, []byte(name))) } func (r *RawFileSystem) Rmdir(cancel <-chan struct{}, header *fuse.InHeader, name string) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, header.Unique) defer done() return toStatus(r.adapter.Rmdir(ctx, header.NodeId, []byte(name))) } @@ -184,7 +195,7 @@ func (r *RawFileSystem) Rename(cancel <-chan struct{}, input *fuse.RenameIn, old if input.Flags != 0 { return toStatus(syscall.EOPNOTSUPP) } - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() return toStatus(r.adapter.Rename(ctx, input.NodeId, []byte(oldName), input.Newdir, []byte(newName))) } @@ -209,7 +220,7 @@ func (r *RawFileSystem) Access(cancel <-chan struct{}, input *fuse.AccessIn) fus } func (r *RawFileSystem) Create(cancel <-chan struct{}, input *fuse.CreateIn, name string, out *fuse.CreateOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() // EAGAIN can hide an already-applied create whose reply was lost. Do not // replay it and turn its original outcome into a collision/open result. @@ -238,7 +249,7 @@ func (r *RawFileSystem) createOrOpen(ctx context.Context, input *fuse.CreateIn, } func (r *RawFileSystem) Open(cancel <-chan struct{}, input *fuse.OpenIn, out *fuse.OpenOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() fh, errno := r.adapter.Open(ctx, input.NodeId) if errno == 0 { @@ -248,7 +259,7 @@ func (r *RawFileSystem) Open(cancel <-chan struct{}, input *fuse.OpenIn, out *fu } func (r *RawFileSystem) Read(cancel <-chan struct{}, input *fuse.ReadIn, _ []byte) (fuse.ReadResult, fuse.Status) { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() data, errno := r.adapter.Read(ctx, input.NodeId, input.Fh, input.Offset, uint64(input.Size)) if errno != 0 { @@ -258,7 +269,7 @@ func (r *RawFileSystem) Read(cancel <-chan struct{}, input *fuse.ReadIn, _ []byt } func (r *RawFileSystem) Write(cancel <-chan struct{}, input *fuse.WriteIn, data []byte) (uint32, fuse.Status) { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() n, errno := r.adapter.Write(ctx, input.NodeId, input.Fh, input.Offset, data) if errno != 0 { @@ -271,7 +282,7 @@ func (r *RawFileSystem) Write(cancel <-chan struct{}, input *fuse.WriteIn, data } func (r *RawFileSystem) Release(cancel <-chan struct{}, input *fuse.ReleaseIn) { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() if errno := r.adapter.Release(ctx, input.NodeId, input.Fh); errno != 0 { slog.WarnContext(ctx, "filesystem FUSE release failed", "inode", input.NodeId, "fh", input.Fh, "errno", errno) @@ -279,13 +290,13 @@ func (r *RawFileSystem) Release(cancel <-chan struct{}, input *fuse.ReleaseIn) { } func (r *RawFileSystem) Flush(cancel <-chan struct{}, input *fuse.FlushIn) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() return toStatus(r.adapter.Flush(ctx, input.NodeId, input.Fh)) } func (r *RawFileSystem) Fsync(cancel <-chan struct{}, input *fuse.FsyncIn) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() datasync := input.FsyncFlags&1 != 0 return toStatus(r.adapter.Fsync(ctx, input.NodeId, input.Fh, datasync)) @@ -438,6 +449,11 @@ func (r *RawFileSystem) requestContext(cancel <-chan struct{}) (context.Context, return requestContextWithin(parent, cancel) } +func (r *RawFileSystem) requestContextWithReply(cancel <-chan struct{}, unique uint64) (context.Context, context.CancelFunc) { + ctx, done := r.requestContext(cancel) + return withReplyIdentity(ctx, unique), done +} + // cancelInFlight ends the context of every request in flight and of any // that arrives later. A nil RawFileSystem is a no-op. func (r *RawFileSystem) cancelInFlight() { @@ -897,13 +913,19 @@ func (s *Server) Unmount() error { if s == nil { return nil } - if s.adapter != nil { - s.adapter.Close() - } if s.inner == nil || s.isReleased() { + if s.adapter != nil { + s.adapter.Close() + } return nil } - return errors.WithStack(s.inner.Unmount()) + if err := s.inner.Unmount(); err != nil { + return errors.WithStack(err) + } + if s.adapter != nil { + s.adapter.Close() + } + return nil } // UnmountLazy detaches the mount from the namespace even while clients still @@ -927,12 +949,18 @@ func (s *Server) UnmountLazy(ctx context.Context) error { } s.fs.cancelInFlight() if s.inner == nil || s.mountPoint == "" { + if s.adapter != nil { + s.adapter.Close() + } return nil } _, err := detachFUSEMount(ctx, s.mountPoint, s.fsType) if errors.Is(err, errNoFUSEMount) { slog.InfoContext(ctx, "filesystem FUSE mount is already gone, nothing to detach", "mount_point", s.mountPoint) - return nil + err = nil + } + if err == nil && s.adapter != nil { + s.adapter.Close() } return err } diff --git a/internal/filesystem/fuseadapter/unmount_context.go b/internal/filesystem/fuseadapter/unmount_context.go index 2c0cfcd29..09fb77599 100644 --- a/internal/filesystem/fuseadapter/unmount_context.go +++ b/internal/filesystem/fuseadapter/unmount_context.go @@ -13,16 +13,23 @@ func (s *Server) UnmountContext(ctx context.Context) error { if s == nil { return nil } - if s.adapter != nil { - s.adapter.Close() - } if s.inner == nil || s.isReleased() || s.mountPoint == "" { + if s.adapter != nil { + s.adapter.Close() + } return nil } // Ordinary unmount can succeed before backend handlers leave Serve. // Cancel their common parent before the lifecycle can close stores. - defer s.fs.cancelInFlight() - return unmountFUSEMount(ctx, s.mountPoint, s.fsType) + err := unmountFUSEMount(ctx, s.mountPoint, s.fsType) + s.fs.cancelInFlight() + if err != nil { + return err + } + if s.adapter != nil { + s.adapter.Close() + } + return nil } func unmountFUSEMount(ctx context.Context, mountPoint, fsType string) error { diff --git a/internal/filesystem/operation_codec_property_test.go b/internal/filesystem/operation_codec_property_test.go index 2a77c0d3f..6b605acdc 100644 --- a/internal/filesystem/operation_codec_property_test.go +++ b/internal/filesystem/operation_codec_property_test.go @@ -26,6 +26,33 @@ func TestOperationRecordCodecProperty(t *testing.T) { }) } +func TestOperationRecordCodecPreservesGenerationFence(t *testing.T) { + id := OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + record := newOperationRecord(id, []byte("write"), 128, 1024) + record.AttemptID = [16]byte{4} + record.Generation = 2 + record.FencedAttempts = [][16]byte{{3}} + record.Reservation += operationFenceReservation + raw, err := encodeOperationRecord(record) + require.NoError(t, err) + decoded, err := decodeOperationRecord(raw) + require.NoError(t, err) + require.Equal(t, record, decoded) + + decoded.FencedAttempts = append(decoded.FencedAttempts, [16]byte{3}) + _, err = encodeOperationRecord(decoded) + require.ErrorIs(t, err, ErrOperationRecordCorrupt) + + legacy := newOperationRecord(id, []byte("write"), 128, 1024) + legacy.RecordIsPrimary = false + raw, err = encodeOperationRecord(legacy) + require.NoError(t, err) + raw[4] = operationRecordLegacyVersion + decoded, err = decodeOperationRecord(raw) + require.NoError(t, err) + require.False(t, decoded.RecordIsPrimary) +} + func TestOperationSessionCodecProperty(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { owner := rapid.StringMatching(`[a-z]{1,16}`).Draw(rt, "owner") diff --git a/internal/filesystem/operation_contention.go b/internal/filesystem/operation_contention.go new file mode 100644 index 000000000..69ca3966c --- /dev/null +++ b/internal/filesystem/operation_contention.go @@ -0,0 +1,43 @@ +package filesystem + +import ( + "context" + + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const operationCASMaxBackoffShift = 5 + +func waitOperationCASRetry(ctx context.Context, retry int) error { + return waitWriteConflictRetry(ctx, min(retry, operationCASMaxBackoffShift)) +} + +func cancelledOperationError(status, contentionStatus uint8) error { + if status == contentionStatus { + return errors.WithStack(ErrContentionLimit) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +// cancelOperationContention retains a terminal result after the last +// definitively rejected attempt has been fenced. A failure to prove the +// cancellation leaves the outcome unknown rather than claiming it was aborted. +func cancelOperationContention(ctx context.Context, journal *OperationJournal, id OperationID, result []byte) (operationRecord, error) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), writeConflictRetryBudget) + defer cancel() + for range operationCASRetries { + record, err := journal.CancelWithResult(cleanupCtx, id, result) + if err == nil { + return record, nil + } + if !isDefinitiveWriteConflict(err) { + break + } + } + record, err := journal.Resolve(cleanupCtx, id) + if err == nil { + return record, nil + } + return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} diff --git a/internal/filesystem/operation_contention_test.go b/internal/filesystem/operation_contention_test.go new file mode 100644 index 000000000..f977170c9 --- /dev/null +++ b/internal/filesystem/operation_contention_test.go @@ -0,0 +1,151 @@ +package filesystem + +import ( + "context" + "encoding/json" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +type operationPublicationConflictDispatcher struct { + inner *testCoordinator + conflicts int +} + +type operationMetadataConflictDispatcher struct { + inner *testCoordinator + remaining int + match func(*kv.OperationGroup[kv.OP]) bool +} + +func (d *operationMetadataConflictDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if d.remaining > 0 && d.match(req) { + d.remaining-- + return nil, store.ErrWriteConflict + } + return d.inner.Dispatch(ctx, req) +} + +func TestOperationMetadataConflictsDoNotReportQuota(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + fault := &operationMetadataConflictDispatcher{inner: inner, remaining: 8, match: func(req *kv.OperationGroup[kv.OP]) bool { + for _, elem := range req.Elems { + if elem != nil && fskeys.IsOperationKey(elem.Key) && validOperationHeader(elem.Value) && + OperationState(elem.Value[5]) == OperationPending { + return true + } + } + return false + }} + svc.dispatch = fault + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, []byte("intent"), 1, 1) + require.NoError(t, err) + require.Zero(t, fault.remaining) + svc.dispatch = inner + _, err = journal.CancelWithResult(ctx, id, []byte{1}) + require.NoError(t, err) + fault = &operationMetadataConflictDispatcher{inner: inner, remaining: 8, match: func(req *kv.OperationGroup[kv.OP]) bool { + for _, elem := range req.Elems { + if elem != nil && elem.Op == kv.Del && fskeys.IsOperationKey(elem.Key) { + return true + } + } + return false + }} + svc.dispatch = fault + require.NoError(t, journal.Acknowledge(ctx, id)) + require.Zero(t, fault.remaining) +} + +func (d *operationPublicationConflictDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + for _, elem := range req.Elems { + if elem == nil || !fskeys.IsOperationKey(elem.Key) { + continue + } + if validOperationHeader(elem.Value) && + (OperationState(elem.Value[5]) == OperationCommitted || OperationState(elem.Value[5]) == OperationRejected) { + d.conflicts++ + return nil, store.ErrWriteConflict + } + } + return d.inner.Dispatch(ctx, req) +} + +func TestHandleContentionIsTerminalAndReplayable(t *testing.T) { + tests := []struct { + name string + run func(*Service, context.Context, OperationID, uint64, uint64, []byte) error + }{ + {"lease", (*Service).RefreshOpenHandleLeaseWithOperation}, + {"release", (*Service).ReleaseWithOperation}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + fault := &operationPublicationConflictDispatcher{inner: inner} + svc.dispatch = fault + require.ErrorIs(t, tt.run(svc, ctx, id, file.Inode, fh, clientID), ErrContentionLimit) + require.Equal(t, writeConflictRetryLimit, fault.conflicts) + svc.dispatch = inner + require.ErrorIs(t, tt.run(svc, ctx, id, file.Inode, fh, clientID), ErrContentionLimit) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationCancelled, record.State) + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) + }) + } +} + +func TestOperationContentionResultsRemainTyped(t *testing.T) { + createResult, err := json.Marshal(createOperationResult{Version: createOperationVersion, Error: createResultContention}) + require.NoError(t, err) + openResult, err := json.Marshal(openOperationResult{Version: openOperationVersion, Error: openResultContention}) + require.NoError(t, err) + setAttrResult, err := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: setAttrResultContention}) + require.NoError(t, err) + tests := []struct { + name string + result []byte + decode func(operationRecord) error + }{ + {"create", createResult, func(r operationRecord) error { _, err := decodeCreateOperationResult(r); return err }}, + {"open", openResult, func(r operationRecord) error { _, err := decodeOpenOperationResult(r); return err }}, + {"setattr", setAttrResult, func(r operationRecord) error { _, err := decodeSetAttrOperationResult(r); return err }}, + {"rename", []byte{renameResultContention}, decodeRenameOperationResult}, + {"unlink", []byte{unlinkResultContention}, decodeUnlinkOperationResult}, + {"lease", []byte{leaseResultContention}, decodeLeaseOperationResult}, + {"release", []byte{releaseResultContention}, decodeReleaseOperationResult}, + {"write", encodeWriteOperationResult(0, writeResultPlanContention), func(r operationRecord) error { _, err := decodeWriteOperationResult(r); return err }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := operationRecord{State: OperationCancelled, Result: tt.result} + require.ErrorIs(t, tt.decode(record), ErrContentionLimit) + record.State = OperationRejected + require.ErrorIs(t, tt.decode(record), ErrOperationRecordCorrupt) + }) + } +} diff --git a/internal/filesystem/operation_crash_test.go b/internal/filesystem/operation_crash_test.go new file mode 100644 index 000000000..4530d577f --- /dev/null +++ b/internal/filesystem/operation_crash_test.go @@ -0,0 +1,93 @@ +package filesystem + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +const operationCrashDirectory = "ELASTICKV_OPERATION_CRASH_DIRECTORY" + +type operationCrashEvidence struct { + IDs []OperationID `json:"ids"` + Inode uint64 `json:"inode"` +} + +func TestJournalReceiptsSurviveProcessKill(t *testing.T) { + if dir := os.Getenv(operationCrashDirectory); dir != "" { + runOperationCrashHelper(t, dir) + return + } + dir := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestJournalReceiptsSurviveProcessKill$") + cmd.Env = append(os.Environ(), operationCrashDirectory+"="+dir) + output, err := cmd.CombinedOutput() + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, string(output)) + status, ok := exit.Sys().(syscall.WaitStatus) + require.True(t, ok, string(output)) + require.Equal(t, syscall.SIGKILL, status.Signal(), string(output)) + raw, err := os.ReadFile(filepath.Join(dir, "evidence.json")) + require.NoError(t, err) + var evidence operationCrashEvidence + require.NoError(t, json.Unmarshal(raw, &evidence)) + require.Len(t, evidence.IDs, 4) + + st, err := store.NewPebbleStore(filepath.Join(dir, "pebble")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close()) }) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + for _, id := range evidence.IDs { + require.NoError(t, svc.VerifyOperationReceipt(t.Context(), id)) + } + inode, err := svc.Resolve(t.Context(), RootInode, []byte("target")) + require.NoError(t, err) + require.Equal(t, evidence.Inode, inode) + data, err := svc.Read(t.Context(), inode, 0, 0, 3) + require.NoError(t, err) + require.Equal(t, []byte("dur"), data) +} + +func runOperationCrashHelper(t *testing.T, dir string) { + ctx := context.Background() + st, err := store.NewPebbleStore(filepath.Join(dir, "pebble")) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + firstID := testWriteOperationID(t, svc, 1) + evidence := operationCrashEvidence{IDs: make([]OperationID, 4)} + for i := range evidence.IDs { + evidence.IDs[i] = firstID + evidence.IDs[i].Sequence = uint64(i + 1) + } + file, err := svc.CreateWithOperation(ctx, evidence.IDs[0], RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + evidence.Inode = file.Inode + _, err = svc.WriteWithOperation(ctx, evidence.IDs[1], file.Inode, 0, []byte("durable")) + require.NoError(t, err) + _, err = svc.SetAttrWithOperation(ctx, evidence.IDs[2], file.Inode, SetAttrMask{Size: true}, SetAttr{Size: 3}) + require.NoError(t, err) + require.NoError(t, svc.RenameWithOperation(ctx, evidence.IDs[3], RootInode, []byte("source"), RootInode, []byte("target"))) + for _, id := range evidence.IDs { + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + } + raw, err := json.Marshal(evidence) + require.NoError(t, err) + fileOnDisk, err := os.OpenFile(filepath.Join(dir, "evidence.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + require.NoError(t, err) + _, err = fileOnDisk.Write(raw) + require.NoError(t, err) + require.NoError(t, fileOnDisk.Sync()) + require.NoError(t, fileOnDisk.Close()) + // Kill without closing Pebble or running test cleanup. + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGKILL)) +} diff --git a/internal/filesystem/operation_create.go b/internal/filesystem/operation_create.go new file mode 100644 index 000000000..015d4d90a --- /dev/null +++ b/internal/filesystem/operation_create.go @@ -0,0 +1,298 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + createOperationVersion = 1 + createOperationResultLimit = 512 + createOperationEffectLimit = 4096 + createResultMissing = 1 + createResultNotDir = 2 + createResultExists = 3 + createResultContention = 4 +) + +type createOperationIntent struct { + Version uint8 `json:"version"` + Parent uint64 `json:"parent"` + Name []byte `json:"name"` + Type FileType `json:"type"` + Options CreateOptions `json:"options"` +} + +type createOperationResult struct { + Version uint8 `json:"version"` + Result CreateResult `json:"result"` + Error uint8 `json:"error"` +} + +// CreateWithOperation retains the created inode, open handle and original Stat. +func (s *Service) CreateWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, opts CreateOptions) (CreateResult, error) { + return s.createNodeWithOperation(ctx, id, parent, name, TypeFile, opts) +} + +// MkdirWithOperation retains the created directory inode and original Stat. +func (s *Service) MkdirWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, opts CreateOptions) (CreateResult, error) { + return s.createNodeWithOperation(ctx, id, parent, name, TypeDirectory, opts) +} + +func (s *Service) createNodeWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, typ FileType, opts CreateOptions) (CreateResult, error) { + if err := validateName(name); err != nil { + return CreateResult{}, err + } + name = bytes.Clone(name) + opts.ClientID = bytes.Clone(opts.ClientID) + intent, err := json.Marshal(createOperationIntent{Version: createOperationVersion, Parent: parent, Name: name, Type: typ, Options: opts}) + if err != nil { + return CreateResult{}, errors.Wrap(err, "encode create intent") + } + effectLimit, err := createEffectLimit(parent, name, opts.ClientID) + if err != nil { + return CreateResult{}, err + } + journal, err := NewOperationJournal(s) + if err != nil { + return CreateResult{}, err + } + admitted, err := journal.Admit(ctx, id, intent, createOperationResultLimit, effectLimit) + if err != nil { + return CreateResult{}, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveCreateOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return CreateResult{}, err + } + return s.executeCreateOperation(ctx, journal, id, attempt, parent, name, typ, opts, intent) +} + +func createEffectLimit(parent uint64, name, clientID []byte) (uint32, error) { + keys := [][]byte{ + fskeys.InodeKey(0), fskeys.HomeKey(0), fskeys.DirEntryKey(parent, name), + fskeys.InodeKey(parent), fskeys.DirVersionKey(parent), + fskeys.UsageRouteKey(kv.RouteKey(fskeys.InodeKey(0))), + } + if len(clientID) > 0 { + keys = append(keys, fskeys.RefKey(0, clientID, 0)) + } + return operationEffectLimitForKeys(createOperationEffectLimit, keys...) +} + +func (s *Service) executeCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (CreateResult, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + result, err := s.publishCreateOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, name, typ, opts, intent) + if !isDefinitiveWriteConflict(err) { + return result, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return CreateResult{}, err + } + if retry == writeConflictRetryLimit-1 { + result, marshalErr := json.Marshal(createOperationResult{Version: createOperationVersion, Error: createResultContention}) + if marshalErr != nil { + return CreateResult{}, errors.Wrap(marshalErr, "encode create contention") + } + record, cancelErr := cancelOperationContention(ctx, journal, id, result) + if cancelErr != nil { + return CreateResult{}, cancelErr + } + return decodeCreateOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return CreateResult{}, err + } + } + return CreateResult{}, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishCreateOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (CreateResult, error) { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return CreateResult{}, err + } + plan, semanticErr, err := s.planCreateOperation(ctx, snapshot, parent, name, typ, opts, intent) + if err != nil { + return CreateResult{}, err + } + if semanticErr != nil { + return rejectCreateOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeCreateOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveCreateAttempt(ctx, journal, id, attempt) + } + return CreateResult{}, err +} + +func (s *Service) planCreateOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (operationAttemptPlan, error, error) { + ts := snapshot.ReadTS + entryKey := fskeys.DirEntryKey(parent, name) + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(parent), entryKey}} + parentMeta, err := s.inodeAt(ctx, parent, ts) + if errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + if parentMeta.Type != TypeDirectory { + return plan, ErrNotDir, nil + } + if _, err = s.dirEntryAt(ctx, parent, name, ts); err == nil { + return plan, ErrExists, nil + } else if !errors.Is(err, ErrNotFound) { + return operationAttemptPlan{}, nil, err + } + inode, err := s.allocateInode(ctx, ts) + if err != nil { + return operationAttemptPlan{}, nil, err + } + result, effects, err := s.createOperationEffects(parent, parentMeta, inode, name, typ, opts) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.ReadKeys = append(plan.ReadKeys, fskeys.InodeKey(inode)) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, ts, effects, plan.ReadKeys, usageDelta{filesAdd: 1}) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Result, err = json.Marshal(createOperationResult{Version: createOperationVersion, Result: result}) + return plan, nil, errors.Wrap(err, "encode create result") +} + +func (s *Service) createOperationEffects(parent uint64, parentMeta *InodeMeta, inode uint64, name []byte, typ FileType, opts CreateOptions) (CreateResult, []*kv.Elem[kv.OP], error) { + now := s.now().UnixNano() + homeSlot := s.homeSlot(inode) + nlink := fileInitialNlink + if typ == TypeDirectory { + nlink = directoryInitialNlink + parentMeta.Nlink++ + } + meta := InodeMeta{Inode: inode, Parent: parent, Type: typ, Mode: opts.Mode, UID: opts.UID, GID: opts.GID, + AtimeNsec: now, MtimeNsec: now, CtimeNsec: now, ChunkSize: s.chunkSize, + HomeSlot: homeSlot, Epoch: initialEpoch, Nlink: nlink, Generation: defaultGeneration} + parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now + effects, err := putElems( + fskeys.InodeKey(inode), meta, + fskeys.HomeKey(inode), Home{HomeSlot: homeSlot, State: HomeStateActive, Epoch: initialEpoch}, + fskeys.DirEntryKey(parent, name), DirEntry{Inode: inode, Type: typ}, + fskeys.InodeKey(parent), parentMeta, + fskeys.DirVersionKey(parent), unixNsecVersion(now), + ) + if err != nil { + return CreateResult{}, nil, err + } + var fh uint64 + if len(opts.ClientID) > 0 { + fh, err = s.allocID() + if err != nil { + return CreateResult{}, nil, err + } + ref, err := s.refPutElem(inode, opts.ClientID, fh) + if err != nil { + return CreateResult{}, nil, err + } + effects = append(effects, ref) + } + return CreateResult{Inode: inode, FH: fh, Stat: meta.Stat()}, effects, nil +} + +func decodeCreateOperationResult(record operationRecord) (CreateResult, error) { + var result createOperationResult + if len(record.Result) == 0 || len(record.Result) > createOperationResultLimit || json.Unmarshal(record.Result, &result) != nil || result.Version != createOperationVersion { + return CreateResult{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected { + return CreateResult{}, rejectedCreateError(result.Error) + } + if record.State == OperationCancelled { + return CreateResult{}, cancelledOperationError(result.Error, createResultContention) + } + if !validCommittedCreateResult(record, result) { + return CreateResult{}, errors.WithStack(ErrOperationRecordCorrupt) + } + return result.Result, nil +} + +func validCommittedCreateResult(record operationRecord, result createOperationResult) bool { + return record.State == OperationCommitted && result.Error == 0 && result.Result.Inode != 0 && + result.Result.Stat.Inode == result.Result.Inode +} + +func rejectedCreateError(status uint8) error { + switch status { + case createResultMissing: + return errors.WithStack(ErrNotFound) + case createResultNotDir: + return errors.WithStack(ErrNotDir) + case createResultExists: + return errors.WithStack(ErrExists) + default: + return errors.WithStack(ErrOperationRecordCorrupt) + } +} + +func resolveCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID) (CreateResult, error) { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return CreateResult{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return CreateResult{}, err + } + return decodeCreateOperationResult(record) +} + +func resolveCreateAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (CreateResult, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeCreateOperationResult(record) + } + return CreateResult{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (CreateResult, error) { + var status uint8 + switch { + case errors.Is(semanticErr, ErrNotFound): + status = createResultMissing + case errors.Is(semanticErr, ErrNotDir): + status = createResultNotDir + case errors.Is(semanticErr, ErrExists): + status = createResultExists + default: + return CreateResult{}, semanticErr + } + result, err := json.Marshal(createOperationResult{Version: createOperationVersion, Error: status}) + if err != nil { + return CreateResult{}, errors.Wrap(err, "encode create rejection") + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) + if err != nil { + if isDefinitiveWriteConflict(err) { + return CreateResult{}, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { + return CreateResult{}, err + } + return resolveCreateAttempt(ctx, journal, id, attempt) + } + return decodeCreateOperationResult(record) +} diff --git a/internal/filesystem/operation_create_test.go b/internal/filesystem/operation_create_test.go new file mode 100644 index 000000000..bb1c5d91a --- /dev/null +++ b/internal/filesystem/operation_create_test.go @@ -0,0 +1,117 @@ +package filesystem + +import ( + "bytes" + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestCreateWithOperationSupportsLongClientID(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + clientID := bytes.Repeat([]byte("a"), 4096) + result, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{ + Mode: testFileMode, ClientID: clientID, + }) + require.NoError(t, err) + require.NotZero(t, result.FH) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, result.Inode, resolved) +} + +func TestCreateWithOperationRetainsOriginalInodeAndHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + opts := CreateOptions{Mode: testFileMode, ClientID: []byte("client")} + original, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), opts) + require.NoError(t, err) + require.NotZero(t, original.FH) + require.NoError(t, svc.Unlink(ctx, RootInode, []byte("file"))) + replacement, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + replayed, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), opts) + require.NoError(t, err) + require.Equal(t, original, replayed) + require.NotEqual(t, replacement.Inode, replayed.Inode) + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("other"), opts) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestMkdirWithOperationDoesNotCreateReplacement(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + original, err := svc.MkdirWithOperation(ctx, id, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + require.NoError(t, svc.Rmdir(ctx, RootInode, []byte("dir"))) + replayed, err := svc.MkdirWithOperation(ctx, id, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + require.Equal(t, original, replayed) + _, err = svc.Resolve(ctx, RootInode, []byte("dir")) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestCreateWithOperationRetainsExistingNameRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + original, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) + } + require.NoError(t, svc.Unlink(ctx, RootInode, []byte("file"))) + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) + _, err = svc.Resolve(ctx, RootInode, []byte("file")) + require.ErrorIs(t, err, ErrNotFound) + require.NotZero(t, original.Inode) +} + +func TestCreateWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.DirEntryKey(RootInode, []byte("file")), loseAck: true} + result, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + require.NotZero(t, result.FH) + replayed, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + require.Equal(t, result, replayed) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, result.Inode, resolved) +} + +func TestCreateWithOperationReplansDefinitiveRejectionConflict(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + _, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, rejectConflict: true} + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) +} diff --git a/internal/filesystem/operation_effect_limit.go b/internal/filesystem/operation_effect_limit.go new file mode 100644 index 000000000..52606cd2e --- /dev/null +++ b/internal/filesystem/operation_effect_limit.go @@ -0,0 +1,40 @@ +package filesystem + +import ( + "math" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/cockroachdb/errors" +) + +const minHandleOperationEffectLimit = 256 + +// handleLeaseEffectLimit reserves the encoded key bytes that the operation +// may publish. The client ID uses ordered-byte escaping in RefKey, so its +// encoded length must be measured rather than inferred from len(clientID). +func handleLeaseEffectLimit(inode uint64, clientID []byte, withFence bool) (uint32, error) { + keys := [][]byte{fskeys.RefKey(inode, clientID, 0)} + if withFence { + keys = append(keys, fskeys.RefFenceKey(inode)) + } + return operationEffectLimitForKeys(minHandleOperationEffectLimit, keys...) +} + +func operationEffectLimitForKeys(minimum uint32, keys ...[]byte) (uint32, error) { + keyBytes, err := operationEffectLimitWithSlack(0, keys...) + if err != nil { + return 0, err + } + return max(minimum, keyBytes), nil +} + +func operationEffectLimitWithSlack(slack uint32, keys ...[]byte) (uint32, error) { + limit := uint64(slack) + for _, key := range keys { + limit += uint64(len(key)) + } + if limit > math.MaxUint32 { + return 0, errors.WithStack(ErrOperationQuota) + } + return uint32(limit), nil +} diff --git a/internal/filesystem/operation_journal.go b/internal/filesystem/operation_journal.go index a09390d1a..5e006910b 100644 --- a/internal/filesystem/operation_journal.go +++ b/internal/filesystem/operation_journal.go @@ -13,7 +13,7 @@ import ( ) const ( - operationCASRetries = 8 + operationCASRetries = 64 maxOperationEffectKeys = 65535 operationJournalRows = 2 ) @@ -23,6 +23,8 @@ type operationJournalBackend interface { GetAt(context.Context, []byte, uint64) ([]byte, error) ScanAt(context.Context, []byte, []byte, int, uint64) ([]*store.KVPair, error) Commit(context.Context, uint64, []*kv.Elem[kv.OP], [][]byte) (*kv.CoordinateResponse, error) + JournalRouteVersion() (uint64, bool) + CommitAtRouteVersion(context.Context, uint64, []*kv.Elem[kv.OP], [][]byte, uint64) (*kv.CoordinateResponse, error) CommittedVersionAt(context.Context, []byte, uint64) (bool, error) CommittedVersionAtGroup(context.Context, []byte, uint64, uint64) (bool, error) GroupIDForKey([]byte) (uint64, error) @@ -45,7 +47,43 @@ func (b serviceOperationBackend) ScanAt(ctx context.Context, start, end []byte, } func (b serviceOperationBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { - return b.service.dispatchTxnWithPolicy(ctx, ts, elems, readKeys, true) + return b.commit(ctx, ts, elems, readKeys, 0) +} + +func (b serviceOperationBackend) JournalRouteVersion() (uint64, bool) { + if source, ok := b.service.dispatch.(interface{ JournalRouteVersion() (uint64, bool) }); ok { + version, ready := source.JournalRouteVersion() + if ready { + return version, true + } + if _, sharded := b.service.store.(*kv.ShardStore); sharded { + return 0, false + } + return 0, true + } + _, sharded := b.service.store.(*kv.ShardStore) + return 0, !sharded +} + +func (b serviceOperationBackend) CommitAtRouteVersion(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte, observed uint64) (*kv.CoordinateResponse, error) { + return b.commit(ctx, ts, elems, readKeys, observed) +} + +func (b serviceOperationBackend) commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte, observed uint64) (*kv.CoordinateResponse, error) { + var primary []byte + for _, elem := range elems { + if elem == nil { + return nil, errors.WithStack(ErrInvalid) + } + if !fskeys.IsOperationKey(elem.Key) { + continue + } + if primary != nil { + return nil, errors.WithStack(ErrInvalid) + } + primary = elem.Key + } + return b.service.dispatchTxnWithPrimaryAtRoute(ctx, ts, elems, readKeys, true, primary, observed) } func (b serviceOperationBackend) CommittedVersionAt(ctx context.Context, key []byte, ts uint64) (bool, error) { @@ -119,7 +157,7 @@ func randomOperationToken() ([16]byte, error) { // EnsureFilesystem returns the single Raft-replicated identity shared by all // mounts. A conflict rereads the winner; a lost response remains unknown. func (j *OperationJournal) EnsureFilesystem(ctx context.Context) ([16]byte, error) { - for range operationCASRetries { + for retry := range operationCASRetries { ts, err := j.backend.Snapshot(ctx) if err != nil { return [16]byte{}, errors.Wrap(err, "fence filesystem identity") @@ -141,8 +179,11 @@ func (j *OperationJournal) EnsureFilesystem(ctx context.Context) ([16]byte, erro if !IsWriteConflict(err) { return [16]byte{}, errors.Wrap(err, "register filesystem identity") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return [16]byte{}, err + } } - return [16]byte{}, errors.WithStack(ErrOperationQuota) + return [16]byte{}, errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) loadFilesystemAt(ctx context.Context, ts uint64) ([16]byte, bool, error) { @@ -185,13 +226,16 @@ func (j *OperationJournal) RegisterSessionWithID(ctx context.Context, id Operati if id.Filesystem == [16]byte{} || id.Session == [16]byte{} || owner == "" { return errors.WithStack(ErrInvalid) } - for range operationCASRetries { + for retry := range operationCASRetries { err := j.registerSessionOnce(ctx, id, owner) if !IsWriteConflict(err) { return err } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) registerSessionOnce(ctx context.Context, id OperationSessionID, owner string) error { @@ -287,14 +331,17 @@ func (j *OperationJournal) Admit(ctx context.Context, id OperationID, intent []b if err := validateOperationRecord(record); err != nil { return operationRecord{}, err } - for range operationCASRetries { + for retry := range operationCASRetries { admitted, err := j.admitOnce(ctx, record) if IsWriteConflict(err) { + if err = waitOperationCASRetry(ctx, retry); err != nil { + return operationRecord{}, err + } continue } return admitted, err } - return operationRecord{}, errors.WithStack(ErrOperationQuota) + return operationRecord{}, errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) admitOnce(ctx context.Context, record operationRecord) (operationRecord, error) { @@ -373,6 +420,10 @@ func (j *OperationJournal) loadRecordAt(ctx context.Context, id OperationID, ts } func (j *OperationJournal) storeRecordAndSession(ctx context.Context, ts uint64, record operationRecord, session operationSession, extra []*kv.Elem[kv.OP], extraReadKeys [][]byte) error { + return j.storeRecordAndSessionAtRoute(ctx, ts, record, session, extra, extraReadKeys, 0) +} + +func (j *OperationJournal) storeRecordAndSessionAtRoute(ctx context.Context, ts uint64, record operationRecord, session operationSession, extra []*kv.Elem[kv.OP], extraReadKeys [][]byte, observed uint64) error { recordRaw, err := encodeOperationRecord(record) if err != nil { return err @@ -394,7 +445,11 @@ func (j *OperationJournal) storeRecordAndSession(ctx context.Context, ts uint64, readKeys := make([][]byte, 0, len(extraReadKeys)+operationJournalRows) readKeys = append(readKeys, record.ID.key(), session.ID.key()) readKeys = append(readKeys, extraReadKeys...) - _, err = j.backend.Commit(ctx, ts, elems, readKeys) + if observed != 0 { + _, err = j.backend.CommitAtRouteVersion(ctx, ts, elems, readKeys, observed) + } else { + _, err = j.backend.Commit(ctx, ts, elems, readKeys) + } return errors.Wrap(err, "publish operation journal") } diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 60b743a96..b807cff0c 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -16,7 +16,7 @@ import ( // BeginAttempt records a fresh transaction generation before any effect is // dispatched. A duplicate caller receives the same active attempt identity. func (j *OperationJournal) BeginAttempt(ctx context.Context, id OperationID) (operationRecord, error) { - for range operationCASRetries { + for retry := range operationCASRetries { ts, record, session, err := j.pendingSnapshot(ctx, id) if err != nil { return operationRecord{}, err @@ -34,29 +34,109 @@ func (j *OperationJournal) BeginAttempt(ctx context.Context, id OperationID) (op record.Generation++ if err = j.storeRecordAndSession(ctx, ts, record, session, nil, nil); err != nil { if IsWriteConflict(err) { + if err = waitOperationCASRetry(ctx, retry); err != nil { + return operationRecord{}, err + } continue } return operationRecord{}, err } return record, nil } - return operationRecord{}, errors.WithStack(ErrOperationGeneration) + return operationRecord{}, errors.WithStack(ErrContentionLimit) } -// AdvanceGeneration cannot proceed until an authoritative transaction-decision -// resolver proves the previous attempt aborted. The journal does not yet -// persist the underlying transaction identity, so it must fail closed rather -// than infer abort from an absent result row or an OCC conflict. -func (j *OperationJournal) AdvanceGeneration(_ context.Context, _ OperationID, _ [16]byte) (operationRecord, error) { +// AdvanceGeneration fences an old attempt by committing a new version of its +// transaction primary. A delayed old publication either committed that primary +// first (making this CAS fail) or must fail its read-set validation against the +// new version. Legacy rows without the primary guarantee remain unresolved. +func (j *OperationJournal) AdvanceGeneration(ctx context.Context, id OperationID, old [16]byte) (operationRecord, error) { + if old == [16]byte{} { + return operationRecord{}, errors.WithStack(ErrInvalid) + } + for retry := range operationCASRetries { + observed, ready := j.backend.JournalRouteVersion() + if !ready { + return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + record, err := j.advanceGenerationOnce(ctx, id, old, observed) + if !IsWriteConflict(err) { + return record, err + } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return operationRecord{}, err + } + } return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } +func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id OperationID, old [16]byte, observed uint64) (operationRecord, error) { + ts, record, session, err := j.pendingSnapshot(ctx, id) + if err != nil { + return operationRecord{}, err + } + if slices.Contains(record.FencedAttempts, old) { + return record, nil + } + record, session, err = prepareGenerationFence(record, session, old) + if err != nil { + return operationRecord{}, err + } + if err = j.storeRecordAndSessionAtRoute(ctx, ts, record, session, nil, nil, observed); err != nil { + return operationRecord{}, err + } + return record, nil +} + +func prepareGenerationFence(record operationRecord, session operationSession, old [16]byte) (operationRecord, operationSession, error) { + if !record.RecordIsPrimary || record.AttemptID != old { + return operationRecord{}, operationSession{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if record.Generation == ^uint64(0) || len(record.FencedAttempts) >= maxOperationFencedAttempts { + return operationRecord{}, operationSession{}, errors.WithStack(ErrOperationGeneration) + } + if operationFenceReservation > session.MaxBytes-session.RetainedBytes { + return operationRecord{}, operationSession{}, errors.WithStack(ErrOperationQuota) + } + next, err := randomOperationToken() + if err != nil { + return operationRecord{}, operationSession{}, err + } + record.FencedAttempts = append(record.FencedAttempts, old) + record.AttemptID = next + record.Generation++ + record.Reservation += operationFenceReservation + session.RetainedBytes += operationFenceReservation + return record, session, nil +} + type operationAttemptPlan struct { - Intent []byte - Effects []*kv.Elem[kv.OP] - ReadTS uint64 - ReadKeys [][]byte - Result []byte + Intent []byte + Effects []*kv.Elem[kv.OP] + ReadTS uint64 + // RouteVersion is captured before the planner reads any routed key. + RouteVersion uint64 + ReadKeys [][]byte + Result []byte +} + +type operationPlanningSnapshot struct { + ReadTS uint64 + RouteVersion uint64 +} + +// PlanSnapshotAtRoute pins the placement used by the planner. A sharded +// planner must carry RouteVersion into publication or semantic rejection. +func (j *OperationJournal) PlanSnapshotAtRoute(ctx context.Context, id OperationID, attempt [16]byte) (operationPlanningSnapshot, error) { + version, ready := j.backend.JournalRouteVersion() + if !ready { + return operationPlanningSnapshot{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + ts, err := j.PlanSnapshot(ctx, id, attempt) + if err != nil { + return operationPlanningSnapshot{}, err + } + return operationPlanningSnapshot{ReadTS: ts, RouteVersion: version}, nil } // PlanSnapshot establishes the OCC timestamp before a planner reads data. @@ -95,10 +175,10 @@ func classifyAttemptOutcome(id OperationID, attempt [16]byte, record operationRe return AttemptUnknown, record, err } if record.ID != id { - return AttemptUnknown, record, err + return AttemptUnknown, record, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } if record.AttemptID != attempt { - return AttemptAborted, record, nil + return classifyPriorAttempt(attempt, record) } if record.State == OperationCommitted && err == nil { return AttemptCommitted, record, nil @@ -109,6 +189,15 @@ func classifyAttemptOutcome(id OperationID, attempt [16]byte, record operationRe return AttemptUnknown, record, err } +func classifyPriorAttempt(attempt [16]byte, record operationRecord) (AttemptOutcome, operationRecord, error) { + if record.RecordIsPrimary && slices.Contains(record.FencedAttempts, attempt) { + return AttemptAborted, record, nil + } + // A different attempt ID without a durable primary fence is not an abort + // certificate for an older transaction. + return AttemptUnknown, record, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + // Publish commits the result record and every effect under the recorded // generation. The session key is also a mutation, so closing the session and // an in-flight attempt cannot both succeed on the same snapshot. @@ -116,6 +205,12 @@ func (j *OperationJournal) Publish(ctx context.Context, id OperationID, attempt if len(plan.ReadKeys) != 0 && plan.ReadTS == 0 { return operationRecord{}, errors.WithStack(ErrInvalid) } + if err := j.requirePlanningRoute(plan.RouteVersion); err != nil { + if duplicate, resolveErr := j.resolveDuplicatePublication(ctx, id, attempt, plan.Intent, ErrOperationGeneration); resolveErr == nil { + return duplicate, nil + } + return operationRecord{}, err + } ts, record, session, err := j.publicationSnapshot(ctx, id, plan.ReadTS) if err != nil { return j.resolveDuplicatePublication(ctx, id, attempt, plan.Intent, err) @@ -137,12 +232,22 @@ func (j *OperationJournal) Publish(ctx context.Context, id OperationID, attempt record.ProofGroups = groups session.Pending-- effects := slices.Concat(routedEffects, proofs) - if err = j.storeRecordAndSession(ctx, ts, record, session, effects, plan.ReadKeys); err != nil { + if err = j.storeRecordAndSessionAtRoute(ctx, ts, record, session, effects, plan.ReadKeys, plan.RouteVersion); err != nil { return j.resolveDuplicatePublication(ctx, id, attempt, plan.Intent, err) } return j.Resolve(ctx, id) } +func (j *OperationJournal) requirePlanningRoute(observed uint64) error { + current, ready := j.backend.JournalRouteVersion() + // A changed catalog invalidates the planner's reads even if the commit + // coordinator could still find a route for every write key. + if !ready || (current != 0 && current != observed) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + return nil +} + func (j *OperationJournal) publicationSnapshot(ctx context.Context, id OperationID, readTS uint64) (uint64, operationRecord, operationSession, error) { if readTS == 0 { return j.pendingSnapshot(ctx, id) @@ -221,31 +326,48 @@ func validateOperationPlan(record operationRecord, session operationSession, pla // validated its predicates on the same snapshot. OCC conflicts remain pending // attempts and must not be stored as semantic rejection. func (j *OperationJournal) Reject(ctx context.Context, id OperationID, attempt [16]byte, readTS uint64, intent []byte, result []byte, readKeys [][]byte) (operationRecord, error) { + return j.RejectAtRoute(ctx, id, attempt, readTS, 0, intent, result, readKeys) +} + +func (j *OperationJournal) RejectAtRoute(ctx context.Context, id OperationID, attempt [16]byte, readTS, routeVersion uint64, intent []byte, result []byte, readKeys [][]byte) (operationRecord, error) { if readTS == 0 { return operationRecord{}, errors.WithStack(ErrInvalid) } + if err := j.requirePlanningRoute(routeVersion); err != nil { + if duplicate, resolveErr := j.resolveDuplicateRejection(ctx, id, attempt, intent, ErrOperationGeneration); resolveErr == nil { + return duplicate, nil + } + return operationRecord{}, err + } ts, record, session, err := j.publicationSnapshot(ctx, id, readTS) if err != nil { return j.resolveDuplicateRejection(ctx, id, attempt, intent, err) } - if attempt == [16]byte{} || record.AttemptID != attempt { - return operationRecord{}, errors.WithStack(ErrOperationGeneration) - } - if record.Fingerprint != sha256.Sum256(intent) || !bytes.Equal(record.Intent, intent) { - return operationRecord{}, errors.WithStack(ErrOperationIDReused) - } - if uint64(len(result)) > uint64(record.ResultLimit) { - return operationRecord{}, errors.WithStack(ErrOperationQuota) + if err := validateOperationRejection(record, attempt, intent, result); err != nil { + return operationRecord{}, err } record.State = OperationRejected record.Result = append([]byte(nil), result...) session.Pending-- - if err = j.storeRecordAndSession(ctx, ts, record, session, nil, readKeys); err != nil { + if err = j.storeRecordAndSessionAtRoute(ctx, ts, record, session, nil, readKeys, routeVersion); err != nil { return j.resolveDuplicateRejection(ctx, id, attempt, intent, err) } return j.Resolve(ctx, id) } +func validateOperationRejection(record operationRecord, attempt [16]byte, intent, result []byte) error { + if attempt == [16]byte{} || record.AttemptID != attempt { + return errors.WithStack(ErrOperationGeneration) + } + if record.Fingerprint != sha256.Sum256(intent) || !bytes.Equal(record.Intent, intent) { + return errors.WithStack(ErrOperationIDReused) + } + if uint64(len(result)) > uint64(record.ResultLimit) { + return errors.WithStack(ErrOperationQuota) + } + return nil +} + func (j *OperationJournal) resolveDuplicateRejection(ctx context.Context, id OperationID, attempt [16]byte, intent []byte, originalErr error) (operationRecord, error) { if !errors.Is(originalErr, ErrOperationGeneration) && !IsWriteConflict(originalErr) { return operationRecord{}, originalErr @@ -260,11 +382,25 @@ func (j *OperationJournal) resolveDuplicateRejection(ctx context.Context, id Ope // Cancel must win an OCC transaction against any active publication. A lost // cancellation response remains unknown until Resolve observes a terminal row. func (j *OperationJournal) Cancel(ctx context.Context, id OperationID) (operationRecord, error) { + return j.cancelWithResult(ctx, id, nil) +} + +// CancelWithResult retains a typed failure when planning ended before any +// publication was dispatched. The result is immutable once cancellation wins. +func (j *OperationJournal) CancelWithResult(ctx context.Context, id OperationID, result []byte) (operationRecord, error) { + return j.cancelWithResult(ctx, id, result) +} + +func (j *OperationJournal) cancelWithResult(ctx context.Context, id OperationID, result []byte) (operationRecord, error) { ts, record, session, err := j.pendingSnapshotAllowClosed(ctx, id, true) if err != nil { return j.resolveWinningCancellation(ctx, id, err) } + if uint64(len(result)) > uint64(record.ResultLimit) { + return operationRecord{}, errors.WithStack(ErrOperationQuota) + } record.State = OperationCancelled + record.Result = append([]byte(nil), result...) session.Pending-- if err = j.storeRecordAndSession(ctx, ts, record, session, nil, nil); err != nil { return operationRecord{}, err @@ -422,7 +558,7 @@ func (j *OperationJournal) Acknowledge(ctx context.Context, id OperationID) erro } return err } - for range operationCASRetries { + for retry := range operationCASRetries { err := j.acknowledgeOnce(ctx, id) if errors.Is(err, ErrOperationRetired) { return nil @@ -433,8 +569,65 @@ func (j *OperationJournal) Acknowledge(ctx context.Context, id OperationID) erro if !IsWriteConflict(err) { return errors.Wrap(err, "acknowledge operation") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +// RetireUnadmitted fences an identity whose reply was attempted before an +// operation record was admitted. The session and record read set makes this +// race atomically with admission: an in-flight admission either wins and must +// be resolved normally, or observes the retired identity and cannot publish. +func (j *OperationJournal) RetireUnadmitted(ctx context.Context, id OperationID) error { + for retry := range operationCASRetries { + err := j.retireUnadmittedOnce(ctx, id) + if err == nil { + return nil + } + if !IsWriteConflict(err) { + return errors.Wrap(err, "retire unadmitted operation") + } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) +} + +func (j *OperationJournal) retireUnadmittedOnce(ctx context.Context, id OperationID) error { + ts, err := j.backend.Snapshot(ctx) + if err != nil { + return errors.Wrap(err, "fence unadmitted operation retirement") + } + session, exists, err := j.loadSessionAt(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session}, ts) + if err != nil { + return err + } + if !exists { + return errors.WithStack(ErrOperationSessionNotFound) + } + if session.retired(id.Sequence) { + return nil + } + _, found, err := j.loadRecordAt(ctx, id, ts) + if err != nil { + return err + } + if found { + return errors.WithStack(ErrOperationGeneration) + } + if err := advanceAckWatermark(&session, id.Sequence); err != nil { + return err + } + raw, err := encodeOperationSession(session) + if err != nil { + return err + } + _, err = j.backend.Commit(ctx, ts, []*kv.Elem[kv.OP]{{Op: kv.Put, Key: session.ID.key(), Value: raw}}, + [][]byte{id.key(), session.ID.key()}) + return errors.Wrap(err, "commit unadmitted operation retirement") } func (j *OperationJournal) acknowledgeOnce(ctx context.Context, id OperationID) error { @@ -507,8 +700,34 @@ func (j *OperationJournal) FenceSession(ctx context.Context, id OperationSession return j.setSessionClosed(ctx, id, true) } +// RecoverOwnerSession fences a previous mount incarnation and resolves its +// pending attempts before the owner registers a replacement session. +func (j *OperationJournal) RecoverOwnerSession(ctx context.Context, filesystemID [16]byte, owner string) error { + if filesystemID == [16]byte{} || owner == "" { + return errors.WithStack(ErrInvalid) + } + ts, err := j.backend.Snapshot(ctx) + if err != nil { + return errors.Wrap(err, "snapshot previous operation owner") + } + previous, exists, err := j.loadOwnerAt(ctx, fskeys.OperationOwnerKey(filesystemID, []byte(owner)), ts) + if err != nil || !exists { + return err + } + id := OperationSessionID{Filesystem: filesystemID, Session: previous} + if err := j.FenceSession(ctx, id); err != nil { + return err + } + for { + resolved, err := j.RecoverClosedSession(ctx, id, defaultIntentRecoveryLimit) + if err != nil || resolved == 0 { + return err + } + } +} + func (j *OperationJournal) setSessionClosed(ctx context.Context, id OperationSessionID, allowPending bool) error { - for range operationCASRetries { + for retry := range operationCASRetries { err := j.closeSessionOnce(ctx, id, allowPending) if err == nil { return nil @@ -516,8 +735,11 @@ func (j *OperationJournal) setSessionClosed(ctx context.Context, id OperationSes if !IsWriteConflict(err) { return errors.Wrap(err, "close operation session") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) closeSessionOnce(ctx context.Context, id OperationSessionID, allowPending bool) error { diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index ecc6bca86..81bc94ad1 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -33,6 +33,140 @@ func testOperationID(t *testing.T, journal *OperationJournal, sequence uint64) O return OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: sequence} } +type primaryCaptureDispatcher struct { + inner *testCoordinator + primary []byte + routeVersion uint64 +} + +func (d *primaryCaptureDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if len(req.PrimaryKey) != 0 { + d.primary = append([]byte(nil), req.PrimaryKey...) + } + if req.ObservedRouteVersion != 0 && req.ObservedRouteVersion != d.routeVersion { + return nil, kv.ErrTransactionOutcomeUnknown + } + return d.inner.Dispatch(ctx, req) +} + +func (d *primaryCaptureDispatcher) JournalRouteVersion() (uint64, bool) { + return d.routeVersion, d.routeVersion != 0 +} + +type routeSwitchOperationBackend struct { + operationJournalBackend + switchRoute func() +} + +func (b *routeSwitchOperationBackend) CommitAtRouteVersion(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], reads [][]byte, observed uint64) (*kv.CoordinateResponse, error) { + b.switchRoute() + return b.operationJournalBackend.CommitAtRouteVersion(ctx, ts, elems, reads, observed) +} + +func TestOperationJournalSelectsRecordAsTransactionPrimary(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + _, err = journal.Publish(ctx, id, attempt.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), + Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("!fs|chk|data"), Value: []byte("value")}}, + }) + require.NoError(t, err) + require.Equal(t, id.key(), dispatch.primary) +} + +func TestOperationJournalGenerationFenceRejectsChangedRouteVersion(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}, routeVersion: 7} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + journal = newOperationJournalWithBackend(&routeSwitchOperationBackend{ + operationJournalBackend: journal.backend, + switchRoute: func() { dispatch.routeVersion = 8 }, + }) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + outcome, _, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationPending) + require.Equal(t, AttemptUnknown, outcome) + current, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.Equal(t, attempt.AttemptID, current.AttemptID) +} + +func TestOperationJournalPublicationRejectsChangedPlanningRoute(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}, routeVersion: 7} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt.AttemptID) + require.NoError(t, err) + require.Equal(t, uint64(7), snapshot.RouteVersion) + dispatch.routeVersion = 8 + _, err = journal.Publish(ctx, id, attempt.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("value")}}, + }) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + _, err = journal.RejectAtRoute(ctx, id, attempt.AttemptID, snapshot.ReadTS, snapshot.RouteVersion, []byte("write"), []byte("rejected"), nil) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + _, err = st.GetAt(ctx, []byte("data"), st.LastCommitTS()) + require.ErrorIs(t, err, store.ErrKeyNotFound) + outcome, _, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationPending) + require.Equal(t, AttemptUnknown, outcome) +} + +func TestOperationJournalDuplicateResultSurvivesPlanningRouteChange(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}, routeVersion: 7} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt.AttemptID) + require.NoError(t, err) + plan := operationAttemptPlan{Intent: []byte("write"), ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("value")}}, Result: []byte("5")} + first, err := journal.Publish(ctx, id, attempt.AttemptID, plan) + require.NoError(t, err) + dispatch.routeVersion = 8 + duplicate, err := journal.Publish(ctx, id, attempt.AttemptID, plan) + require.NoError(t, err) + require.Equal(t, first.Result, duplicate.Result) +} + func TestOperationJournalPublishesResultWithEffectAndRetiresIdentity(t *testing.T) { ctx := context.Background() journal, st, coord := testOperationJournal(t) @@ -204,6 +338,29 @@ type delayedOperationCommitBackend struct { release chan struct{} } +type lostGenerationFenceBackend struct { + operationJournalBackend + lose bool +} + +func (b *lostGenerationFenceBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { + response, err := b.operationJournalBackend.Commit(ctx, ts, elems, readKeys) + if err != nil { + return response, err + } + for _, elem := range elems { + if !fskeys.IsOperationKey(elem.Key) { + continue + } + record, decodeErr := decodeOperationRecord(elem.Value) + if decodeErr == nil && len(record.FencedAttempts) != 0 && b.lose { + b.lose = false + return nil, kv.ErrTransactionOutcomeUnknown + } + } + return response, nil +} + func (b *delayedOperationCommitBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { for _, elem := range elems { if bytes.Equal(elem.Key, []byte("data")) { @@ -537,6 +694,76 @@ func TestOperationJournalOwnerFenceAndRecovery(t *testing.T) { require.ErrorIs(t, err, ErrOperationSessionClosed) } +func TestOperationJournalRecoverOwnerSession(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + _, err = journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.NoError(t, journal.RecoverOwnerSession(ctx, id.Filesystem, "mount-a")) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationCancelled, record.State) + replacement, err := journal.RegisterSession(ctx, id.Filesystem, "mount-a") + require.NoError(t, err) + require.NotEqual(t, id.Session, replacement.Session) + require.NoError(t, journal.RecoverOwnerSession(ctx, id.Filesystem, "mount-a")) +} + +func TestAcknowledgeOperationCancelsPendingReply(t *testing.T) { + ctx := context.Background() + journal, st, coord := testOperationJournal(t) + svc, err := NewService(st, coord) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("release"), 128, 1024) + require.NoError(t, err) + _, err = journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.NoError(t, svc.AcknowledgeOperation(ctx, id)) + _, err = journal.Resolve(ctx, id) + require.ErrorIs(t, err, ErrOperationRetired) + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) +} + +func TestAcknowledgeOperationRetiresUnadmittedIdentity(t *testing.T) { + ctx := context.Background() + journal, st, coord := testOperationJournal(t) + svc, err := NewService(st, coord) + require.NoError(t, err) + first := testOperationID(t, journal, 1) + second := OperationID{Filesystem: first.Filesystem, Session: first.Session, Sequence: 2} + _, err = journal.Admit(ctx, second, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, second) + require.NoError(t, err) + _, err = journal.Publish(ctx, second, attempt.AttemptID, operationAttemptPlan{Intent: []byte("write"), Result: []byte("done")}) + require.NoError(t, err) + require.NoError(t, svc.AcknowledgeOperation(ctx, second)) + require.NoError(t, svc.AcknowledgeOperation(ctx, first)) + require.NoError(t, svc.AcknowledgeOperation(ctx, first)) + for _, id := range []OperationID{first, second} { + _, err = journal.Resolve(ctx, id) + require.ErrorIs(t, err, ErrOperationRetired) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.ErrorIs(t, err, ErrOperationRetired) + } + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: first.Filesystem, Session: first.Session})) +} + +func TestRetireUnadmittedDoesNotRetireAdmittedIdentity(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + require.ErrorIs(t, journal.RetireUnadmitted(ctx, id), ErrOperationGeneration) + _, err = journal.Resolve(ctx, id) + require.ErrorIs(t, err, ErrOperationPending) +} + func TestOperationJournalAcknowledgementGapsRetireWithoutReuse(t *testing.T) { ctx := context.Background() journal, _, _ := testOperationJournal(t) @@ -562,7 +789,7 @@ func TestOperationJournalAcknowledgementGapsRetireWithoutReuse(t *testing.T) { } } -func TestOperationJournalDoesNotAdvanceWithoutAbortDecision(t *testing.T) { +func TestOperationJournalDoesNotAdvanceLegacyAttempt(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) id := testOperationID(t, journal, 1) @@ -570,6 +797,10 @@ func TestOperationJournalDoesNotAdvanceWithoutAbortDecision(t *testing.T) { require.NoError(t, err) old, err := journal.BeginAttempt(ctx, id) require.NoError(t, err) + ts, legacy, session, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + legacy.RecordIsPrimary = false + require.NoError(t, journal.storeRecordAndSession(ctx, ts, legacy, session, nil, nil)) _, err = journal.AdvanceGeneration(ctx, id, old.AttemptID) require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) outcome, _, err := journal.ResolveAttempt(ctx, id, old.AttemptID) @@ -589,7 +820,44 @@ func TestOperationJournalDoesNotAdvanceWithoutAbortDecision(t *testing.T) { require.Equal(t, []byte("old"), value) } -func TestOperationJournalDelayedCommitCannotBeSupersededWithoutDecision(t *testing.T) { +func TestOperationJournalNewGenerationDoesNotProveOldAttemptAborted(t *testing.T) { + id := OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + oldAttempt := [16]byte{3} + newAttempt := [16]byte{4} + for _, tc := range []struct { + name string + state OperationState + }{ + {name: "pending", state: OperationPending}, + {name: "committed", state: OperationCommitted}, + {name: "rejected", state: OperationRejected}, + {name: "cancelled", state: OperationCancelled}, + } { + t.Run(tc.name, func(t *testing.T) { + record := operationRecord{ID: id, State: tc.state, AttemptID: newAttempt, Generation: 2} + var resolutionErr error + if tc.state == OperationPending { + resolutionErr = ErrOperationPending + } + outcome, _, err := classifyAttemptOutcome(id, oldAttempt, record, resolutionErr) + require.Equal(t, AttemptUnknown, outcome) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + }) + } +} + +func TestOperationJournalForeignRecordNeverProvesAttemptOutcome(t *testing.T) { + id := OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + foreign := OperationID{Filesystem: id.Filesystem, Session: id.Session, Sequence: 2} + attempt := [16]byte{3} + outcome, _, err := classifyAttemptOutcome(id, attempt, operationRecord{ + ID: foreign, State: OperationCommitted, AttemptID: attempt, + }, nil) + require.Equal(t, AttemptUnknown, outcome) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) +} + +func TestOperationJournalGenerationFenceStopsDelayedOldCommit(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) id := testOperationID(t, journal, 1) @@ -611,16 +879,112 @@ func TestOperationJournalDelayedCommitCannotBeSupersededWithoutDecision(t *testi result <- publishErr }() <-backend.entered - _, err = journal.AdvanceGeneration(ctx, id, old.AttemptID) - require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + next, err := journal.AdvanceGeneration(ctx, id, old.AttemptID) + require.NoError(t, err) + require.NotEqual(t, old.AttemptID, next.AttemptID) + require.Equal(t, old.Generation+1, next.Generation) close(backend.release) - require.NoError(t, <-result) + require.Error(t, <-result) current, err := journal.BeginAttempt(ctx, id) - require.ErrorIs(t, err, ErrOperationGeneration) - require.Equal(t, operationRecord{}, current) + require.NoError(t, err) + require.Equal(t, next.AttemptID, current.AttemptID) + _, err = st.GetAt(ctx, []byte("data"), st.LastCommitTS()) + require.ErrorIs(t, err, store.ErrKeyNotFound) + outcome, _, err := journal.ResolveAttempt(ctx, id, old.AttemptID) + require.NoError(t, err) + require.Equal(t, AttemptAborted, outcome) + _, err = journal.Publish(ctx, id, next.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("new")}}, + }) + require.NoError(t, err) value, err := st.GetAt(ctx, []byte("data"), st.LastCommitTS()) require.NoError(t, err) - require.Equal(t, []byte("old"), value) + require.Equal(t, []byte("new"), value) +} + +func TestOperationJournalCommittedAttemptWinsGenerationFence(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + _, err = journal.Publish(ctx, id, attempt.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("old")}}, + }) + require.NoError(t, err) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationGeneration) + outcome, _, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.NoError(t, err) + require.Equal(t, AttemptCommitted, outcome) +} + +func TestOperationJournalLostGenerationFenceReplyRetainsAbortProof(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + journal = newOperationJournalWithBackend(&lostGenerationFenceBackend{operationJournalBackend: journal.backend, lose: true}) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.NoError(t, err) + require.Equal(t, AttemptAborted, outcome) + require.NotEqual(t, attempt.AttemptID, record.AttemptID) + current, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.Equal(t, record.AttemptID, current.AttemptID) +} + +func TestOperationJournalGenerationFenceRespectsSessionQuota(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + ts, record, session, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + session.MaxBytes = session.RetainedBytes + require.NoError(t, journal.storeRecordAndSession(ctx, ts, record, session, nil, nil)) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationQuota) + current, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.Equal(t, attempt.AttemptID, current.AttemptID) +} + +func TestOperationJournalGenerationFenceReleasesReservedBytesOnAcknowledgement(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + beforeTS, _, before, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + require.NotZero(t, beforeTS) + next, err := journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.NoError(t, err) + _, _, during, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + require.Equal(t, before.RetainedBytes+operationFenceReservation, during.RetainedBytes) + _, err = journal.Publish(ctx, id, next.AttemptID, operationAttemptPlan{Intent: []byte("write")}) + require.NoError(t, err) + require.NoError(t, journal.Acknowledge(ctx, id)) + ts, err := journal.backend.Snapshot(ctx) + require.NoError(t, err) + after, exists, err := journal.loadSessionAt(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session}, ts) + require.NoError(t, err) + require.True(t, exists) + require.Zero(t, after.RetainedBytes) } func TestOperationJournalSnapshotRetainsTerminalAndPendingRecords(t *testing.T) { diff --git a/internal/filesystem/operation_lease.go b/internal/filesystem/operation_lease.go new file mode 100644 index 000000000..9ccf3e99a --- /dev/null +++ b/internal/filesystem/operation_lease.go @@ -0,0 +1,172 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +const ( + leaseOperationVersion = 1 + leaseOperationResultLimit = 1 + leaseResultSuccess = 0 + leaseResultMissing = 1 + leaseResultContention = 2 +) + +type leaseOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + FH uint64 `json:"fh"` + ClientID []byte `json:"client_id"` +} + +// RefreshOpenHandleLeaseWithOperation cannot recreate a released or reaped lease. +func (s *Service) RefreshOpenHandleLeaseWithOperation(ctx context.Context, id OperationID, inode, fh uint64, clientID []byte) error { + clientID = bytes.Clone(clientID) + intent, err := json.Marshal(leaseOperationIntent{Version: leaseOperationVersion, Inode: inode, FH: fh, ClientID: clientID}) + if err != nil { + return errors.Wrap(err, "encode lease intent") + } + effectLimit, err := handleLeaseEffectLimit(inode, clientID, false) + if err != nil { + return err + } + journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, leaseOperationResultLimit, effectLimit) + if err != nil { + return err + } + if terminal { + return resolveLeaseOperation(ctx, journal, id) + } + return s.executeLeaseOperation(ctx, journal, id, attempt, inode, fh, clientID, intent) +} + +func (s *Service) executeLeaseOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, fh uint64, clientID, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishLeaseOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, fh, clientID, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{leaseResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeLeaseOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishLeaseOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, fh uint64, clientID, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, semanticErr, err := s.planLeaseOperation(ctx, snapshot, inode, fh, clientID, intent) + if err != nil { + return err + } + if semanticErr != nil { + return rejectLeaseOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeLeaseOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveLeaseAttempt(ctx, journal, id, attempt) + } + return err +} + +func (s *Service) planLeaseOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode, fh uint64, clientID, intent []byte) (operationAttemptPlan, error, error) { + refKey := fskeys.RefKey(inode, clientID, fh) + plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(inode), refKey}} + if _, err := s.inodeAt(ctx, inode, snapshot.ReadTS); errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } else if err != nil { + return operationAttemptPlan{}, nil, err + } + if _, err := s.store.GetAt(ctx, refKey, snapshot.ReadTS); errors.Is(err, store.ErrKeyNotFound) { + return plan, ErrNotFound, nil + } else if err != nil { + return operationAttemptPlan{}, nil, errors.Wrap(err, "read open handle lease") + } + elem, err := s.refPutElem(inode, clientID, fh) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Effects = []*kv.Elem[kv.OP]{elem} + plan.Result = []byte{leaseResultSuccess} + return plan, nil, nil +} + +func decodeLeaseOperationResult(record operationRecord) error { + if len(record.Result) != 1 { + return errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCommitted && record.Result[0] == leaseResultSuccess { + return nil + } + if record.State == OperationRejected && record.Result[0] == leaseResultMissing { + return errors.WithStack(ErrNotFound) + } + if record.State == OperationCancelled && record.Result[0] == leaseResultContention { + return errors.WithStack(ErrContentionLimit) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +func resolveLeaseOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeLeaseOperationResult(record) +} + +func resolveLeaseAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeLeaseOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectLeaseOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) error { + if !errors.Is(semanticErr, ErrNotFound) { + return semanticErr + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + []byte{leaseResultMissing}, plan.ReadKeys) + if err != nil { + if isDefinitiveWriteConflict(err) { + return err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { + return err + } + return resolveLeaseAttempt(ctx, journal, id, attempt) + } + return decodeLeaseOperationResult(record) +} diff --git a/internal/filesystem/operation_lease_test.go b/internal/filesystem/operation_lease_test.go new file mode 100644 index 000000000..72af2a3ef --- /dev/null +++ b/internal/filesystem/operation_lease_test.go @@ -0,0 +1,52 @@ +package filesystem + +import ( + "context" + "testing" + "time" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestValidateOpenHandleLeaseDoesNotRenewExpiredHandle(t *testing.T) { + ctx := context.Background() + now := time.Unix(100, 0) + svc := newTestServiceWithOptions(t, []uint64{2, 3}, + WithClock(func() time.Time { return now }), WithOpenHandleLeaseTTL(time.Second)) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + require.Equal(t, time.Second, svc.OpenHandleLeaseTTL()) + require.NoError(t, svc.ValidateOpenHandleLease(ctx, file.Inode, fh, clientID)) + now = now.Add(2 * time.Second) + require.ErrorIs(t, svc.ValidateOpenHandleLease(ctx, file.Inode, fh, clientID), ErrNotFound) + require.ErrorIs(t, svc.ValidateOpenHandleLease(ctx, file.Inode, fh+1, clientID), ErrNotFound) +} + +func TestLeaseOperationCannotResurrectReleasedHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + firstID := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, firstID, file.Inode, fh, clientID)) + require.NoError(t, svc.Release(ctx, file.Inode, fh, clientID)) + require.NoError(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, firstID, file.Inode, fh, clientID)) + missingID := firstID + missingID.Sequence = 2 + require.ErrorIs(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, missingID, file.Inode, fh, clientID), ErrNotFound) + require.ErrorIs(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, missingID, file.Inode, fh, clientID), ErrNotFound) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.False(t, exists) +} diff --git a/internal/filesystem/operation_namespace_reject.go b/internal/filesystem/operation_namespace_reject.go new file mode 100644 index 000000000..bca8786ee --- /dev/null +++ b/internal/filesystem/operation_namespace_reject.go @@ -0,0 +1,26 @@ +package filesystem + +import ( + "context" + + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +// rejectNamespaceOperation preserves a definitive OCC conflict for the caller's +// generation-fencing retry loop. Only an uncertain decision is resolved. +func rejectNamespaceOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, status byte, decode func(operationRecord) error) error { + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + []byte{status}, plan.ReadKeys) + if err == nil { + return decode(record) + } + if isDefinitiveWriteConflict(err) || (!errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration)) { + return err + } + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decode(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} diff --git a/internal/filesystem/operation_open.go b/internal/filesystem/operation_open.go new file mode 100644 index 000000000..6ddf3496a --- /dev/null +++ b/internal/filesystem/operation_open.go @@ -0,0 +1,225 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +const ( + openOperationVersion = 1 + openOperationResultLimit = 128 + openResultMissing = 1 + openResultContention = 2 +) + +type openOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + ClientID []byte `json:"client_id"` +} + +type openOperationResult struct { + Version uint8 `json:"version"` + FH uint64 `json:"fh"` + Error uint8 `json:"error"` +} + +// OpenWithOperation retains the original handle and its lease publication. +func (s *Service) OpenWithOperation(ctx context.Context, id OperationID, inode uint64, clientID []byte) (uint64, error) { + clientID = bytes.Clone(clientID) + intent, err := json.Marshal(openOperationIntent{Version: openOperationVersion, Inode: inode, ClientID: clientID}) + if err != nil { + return 0, errors.Wrap(err, "encode open intent") + } + effectLimit, err := handleLeaseEffectLimit(inode, clientID, true) + if err != nil { + return 0, err + } + journal, err := NewOperationJournal(s) + if err != nil { + return 0, err + } + admitted, err := journal.Admit(ctx, id, intent, openOperationResultLimit, effectLimit) + if err != nil { + return 0, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveOpenOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return 0, err + } + return s.executeOpenOperation(ctx, journal, id, attempt, inode, clientID, intent) +} + +func (s *Service) executeOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode uint64, clientID, intent []byte) (uint64, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + fh, err := s.publishOpenOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, clientID, intent) + if !isDefinitiveWriteConflict(err) { + return fh, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return 0, err + } + if retry == writeConflictRetryLimit-1 { + result, marshalErr := json.Marshal(openOperationResult{Version: openOperationVersion, Error: openResultContention}) + if marshalErr != nil { + return 0, errors.Wrap(marshalErr, "encode open contention") + } + record, cancelErr := cancelOperationContention(ctx, journal, id, result) + if cancelErr != nil { + return 0, cancelErr + } + return decodeOpenOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return 0, err + } + } + return 0, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishOpenOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, clientID, intent []byte) (uint64, error) { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return 0, err + } + plan, semanticErr, err := s.planOpenOperation(ctx, snapshot, inode, clientID, intent) + if err != nil { + return 0, err + } + if semanticErr != nil { + return rejectOpenOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeOpenOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveOpenAttempt(ctx, journal, id, attempt) + } + return 0, err +} + +func (s *Service) planOpenOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode uint64, clientID, intent []byte) (operationAttemptPlan, error, error) { + ts := snapshot.ReadTS + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(inode)}} + meta, err := s.inodeAt(ctx, inode, ts) + if errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + if meta.Orphaned || meta.Nlink == 0 { + return plan, ErrNotFound, nil + } + fh, err := s.allocID() + if err != nil { + return operationAttemptPlan{}, nil, err + } + refElem, fenceElem, refKey, err := s.planOpenLease(ctx, inode, clientID, fh, ts) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.ReadKeys = append(plan.ReadKeys, refKey, fskeys.RefFenceKey(inode)) + plan.Effects = []*kv.Elem[kv.OP]{refElem, fenceElem} + plan.Result, err = json.Marshal(openOperationResult{Version: openOperationVersion, FH: fh}) + return plan, nil, errors.Wrap(err, "encode open result") +} + +func (s *Service) planOpenLease(ctx context.Context, inode uint64, clientID []byte, fh, ts uint64) (*kv.Elem[kv.OP], *kv.Elem[kv.OP], []byte, error) { + if fh == 0 { + return nil, nil, nil, errors.WithStack(store.ErrWriteConflict) + } + refKey := fskeys.RefKey(inode, clientID, fh) + if _, err := s.store.GetAt(ctx, refKey, ts); err == nil { + return nil, nil, nil, errors.WithStack(store.ErrWriteConflict) + } else if !errors.Is(err, store.ErrKeyNotFound) { + return nil, nil, nil, errors.Wrap(err, "read open handle lease") + } + refElem, err := s.refPutElem(inode, clientID, fh) + if err != nil { + return nil, nil, nil, err + } + fenceElem, err := s.refFenceElem(inode) + if err != nil { + return nil, nil, nil, err + } + return refElem, fenceElem, refKey, nil +} + +func decodeOpenOperationResult(record operationRecord) (uint64, error) { + var result openOperationResult + if len(record.Result) == 0 || len(record.Result) > openOperationResultLimit || json.Unmarshal(record.Result, &result) != nil || result.Version != openOperationVersion { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected { + if result.Error == openResultMissing { + return 0, errors.WithStack(ErrNotFound) + } + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCancelled { + return 0, cancelledOperationError(result.Error, openResultContention) + } + if !validCommittedOpenResult(record, result) { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return result.FH, nil +} + +func validCommittedOpenResult(record operationRecord, result openOperationResult) bool { + return record.State == OperationCommitted && result.Error == 0 && result.FH != 0 +} + +func resolveOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID) (uint64, error) { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return 0, err + } + return decodeOpenOperationResult(record) +} + +func resolveOpenAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (uint64, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeOpenOperationResult(record) + } + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (uint64, error) { + if !errors.Is(semanticErr, ErrNotFound) { + return 0, semanticErr + } + result, err := json.Marshal(openOperationResult{Version: openOperationVersion, Error: openResultMissing}) + if err != nil { + return 0, errors.Wrap(err, "encode open rejection") + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) + if err != nil { + if isDefinitiveWriteConflict(err) { + return 0, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { + return 0, err + } + return resolveOpenAttempt(ctx, journal, id, attempt) + } + return decodeOpenOperationResult(record) +} diff --git a/internal/filesystem/operation_open_test.go b/internal/filesystem/operation_open_test.go new file mode 100644 index 000000000..6825e05d0 --- /dev/null +++ b/internal/filesystem/operation_open_test.go @@ -0,0 +1,82 @@ +package filesystem + +import ( + "bytes" + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestHandleOperationsSupportLongClientID(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := bytes.Repeat([]byte{0}, 300) // ordered-byte encoding expands NUL bytes + openID := testWriteOperationID(t, svc, 1) + fh, err := svc.OpenWithOperation(ctx, openID, file.Inode, clientID) + require.NoError(t, err) + require.NoError(t, svc.VerifyOperationReceipt(ctx, openID)) + leaseID := openID + leaseID.Sequence = 2 + require.NoError(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, leaseID, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, leaseID)) +} + +func TestOpenWithOperationRetainsOneHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + clientID := []byte("mount") + fh, err := svc.OpenWithOperation(ctx, id, file.Inode, clientID) + require.NoError(t, err) + otherFH, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + replayed, err := svc.OpenWithOperation(ctx, id, file.Inode, clientID) + require.NoError(t, err) + require.Equal(t, fh, replayed) + require.NotEqual(t, fh, otherFH) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.True(t, exists) + _, err = svc.OpenWithOperation(ctx, id, file.Inode, []byte("other")) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestOpenWithOperationRetainsMissingInode(t *testing.T) { + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(t.Context(), testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err := svc.OpenWithOperation(t.Context(), id, 999, []byte("mount")) + require.ErrorIs(t, err, ErrNotFound) + } +} + +func TestOpenWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.RefKey(file.Inode, []byte("mount"), 3), loseAck: true} + fh, err := svc.OpenWithOperation(ctx, id, file.Inode, []byte("mount")) + require.NoError(t, err) + require.EqualValues(t, 3, fh) + replayed, err := svc.OpenWithOperation(ctx, id, file.Inode, []byte("mount")) + require.NoError(t, err) + require.Equal(t, fh, replayed) +} diff --git a/internal/filesystem/operation_receipt.go b/internal/filesystem/operation_receipt.go new file mode 100644 index 000000000..6afbfb028 --- /dev/null +++ b/internal/filesystem/operation_receipt.go @@ -0,0 +1,51 @@ +package filesystem + +import ( + "context" + + "github.com/cockroachdb/errors" +) + +// VerifyOperationReceipt reads the authoritative terminal record and checks +// every participant proof before a local FUSE barrier can discharge it. +func (s *Service) VerifyOperationReceipt(ctx context.Context, id OperationID) error { + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + record, err := journal.Resolve(ctx, id) + if err != nil { + return err + } + if record.State != OperationCommitted { + return errors.WithStack(ErrOperationRecordCorrupt) + } + return nil +} + +// AcknowledgeOperation retires a terminal operation after its FUSE reply was +// attempted and its participant proofs were verified by the adapter. +func (s *Service) AcknowledgeOperation(ctx context.Context, id OperationID) error { + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + if _, err := journal.Resolve(ctx, id); errors.Is(err, ErrOperationPending) { + if _, err := journal.Cancel(ctx, id); errors.Is(err, ErrOperationPending) { + return journal.RetireUnadmitted(ctx, id) + } else if err != nil { + return err + } + } else if err != nil && !errors.Is(err, ErrOperationRetired) { + return err + } + return journal.Acknowledge(ctx, id) +} + +func (s *Service) CloseOperationSession(ctx context.Context, id OperationSessionID) error { + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + return journal.CloseSession(ctx, id) +} diff --git a/internal/filesystem/operation_receipt_test.go b/internal/filesystem/operation_receipt_test.go new file mode 100644 index 000000000..b5710aab7 --- /dev/null +++ b/internal/filesystem/operation_receipt_test.go @@ -0,0 +1,136 @@ +package filesystem + +import ( + "context" + "path/filepath" + "testing" + + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +func TestVerifyOperationReceiptAfterPebbleReopen(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "pebble") + st, err := store.NewPebbleStore(dir) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("durable")) + require.NoError(t, err) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + require.NoError(t, st.Close()) + + reopened, err := store.NewPebbleStore(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restarted, err := NewService(reopened, &testCoordinator{st: reopened}) + require.NoError(t, err) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, id)) + data, err := restarted.Read(ctx, file.Inode, 0, 0, 7) + require.NoError(t, err) + require.Equal(t, []byte("durable"), data) +} + +func TestNamespaceAndMetadataReceiptsAfterPebbleReopen(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "pebble") + st, err := store.NewPebbleStore(dir) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + removed, err := svc.Create(ctx, RootInode, []byte("removed"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + _, err = svc.Mkdir(ctx, RootInode, []byte("empty"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + + ids := make([]OperationID, 5) + firstID := testWriteOperationID(t, svc, 1) + for i := range ids { + ids[i] = firstID + ids[i].Sequence = uint64(i + 1) + } + _, err = svc.WriteWithOperation(ctx, ids[0], file.Inode, 0, []byte("durable")) + require.NoError(t, err) + _, err = svc.SetAttrWithOperation(ctx, ids[1], file.Inode, SetAttrMask{Size: true}, SetAttr{Size: 3}) + require.NoError(t, err) + require.NoError(t, svc.RenameWithOperation(ctx, ids[2], RootInode, []byte("source"), RootInode, []byte("target"))) + require.NoError(t, svc.UnlinkWithOperation(ctx, ids[3], RootInode, []byte("removed"))) + require.NoError(t, svc.RmdirWithOperation(ctx, ids[4], RootInode, []byte("empty"))) + for _, id := range ids { + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + } + require.NoError(t, st.Close()) + + reopened, err := store.NewPebbleStore(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restarted, err := NewService(reopened, &testCoordinator{st: reopened}) + require.NoError(t, err) + for _, id := range ids { + require.NoError(t, restarted.VerifyOperationReceipt(ctx, id)) + } + inode, err := restarted.Resolve(ctx, RootInode, []byte("target")) + require.NoError(t, err) + require.Equal(t, file.Inode, inode) + data, err := restarted.Read(ctx, inode, 0, 0, 3) + require.NoError(t, err) + require.Equal(t, []byte("dur"), data) + _, err = restarted.Resolve(ctx, RootInode, []byte("source")) + require.ErrorIs(t, err, ErrNotFound) + _, err = restarted.Resolve(ctx, RootInode, []byte("removed")) + require.ErrorIs(t, err, ErrNotFound) + _, err = restarted.Resolve(ctx, RootInode, []byte("empty")) + require.ErrorIs(t, err, ErrNotFound) + require.NotEqual(t, file.Inode, removed.Inode) +} + +func TestCreateReceiptsAfterPebbleReopen(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "pebble") + st, err := store.NewPebbleStore(dir) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + fileID := testWriteOperationID(t, svc, 1) + dirID := fileID + dirID.Sequence = 2 + openID := fileID + openID.Sequence = 3 + file, err := svc.CreateWithOperation(ctx, fileID, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + createdDir, err := svc.MkdirWithOperation(ctx, dirID, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + fh, err := svc.OpenWithOperation(ctx, openID, file.Inode, []byte("mount")) + require.NoError(t, err) + require.NoError(t, svc.VerifyOperationReceipt(ctx, fileID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, dirID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, openID)) + require.NoError(t, st.Close()) + + reopened, err := store.NewPebbleStore(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restarted, err := NewService(reopened, &testCoordinator{st: reopened}) + require.NoError(t, err) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, fileID)) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, dirID)) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, openID)) + replayed, err := restarted.CreateWithOperation(ctx, fileID, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + require.Equal(t, file, replayed) + replayedDir, err := restarted.MkdirWithOperation(ctx, dirID, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + require.Equal(t, createdDir, replayedDir) + replayedFH, err := restarted.OpenWithOperation(ctx, openID, file.Inode, []byte("mount")) + require.NoError(t, err) + require.Equal(t, fh, replayedFH) +} diff --git a/internal/filesystem/operation_record.go b/internal/filesystem/operation_record.go index 2ec9b3ddf..5a08aa245 100644 --- a/internal/filesystem/operation_record.go +++ b/internal/filesystem/operation_record.go @@ -12,12 +12,15 @@ import ( ) const ( - operationRecordVersion = 2 + operationRecordVersion = 3 + operationRecordLegacyVersion = 2 operationHeaderBytes = 16 operationCommitTSOffset = 8 maxOperationRecordBytes = 8 << 20 operationReservationOverhead = 4096 maxOperationProofGroups = 16 + maxOperationFencedAttempts = 256 + operationFenceReservation = 128 ) var ( @@ -54,25 +57,31 @@ type operationRecord struct { Intent []byte `json:"intent"` Generation uint64 `json:"generation"` AttemptID [16]byte `json:"attempt_id"` - Result []byte `json:"result,omitempty"` - EffectKeys [][]byte `json:"effect_keys,omitempty"` - ProofGroups []uint64 `json:"proof_groups,omitempty"` - Reservation uint64 `json:"reservation"` - ResultLimit uint32 `json:"result_limit"` - EffectLimit uint32 `json:"effect_limit"` - CommitTS uint64 `json:"-"` + // RecordIsPrimary certifies that each publication of this operation + // selects this row as its transaction primary. Legacy rows omit it and + // cannot use generation fencing. + RecordIsPrimary bool `json:"record_is_primary,omitempty"` + FencedAttempts [][16]byte `json:"fenced_attempts,omitempty"` + Result []byte `json:"result,omitempty"` + EffectKeys [][]byte `json:"effect_keys,omitempty"` + ProofGroups []uint64 `json:"proof_groups,omitempty"` + Reservation uint64 `json:"reservation"` + ResultLimit uint32 `json:"result_limit"` + EffectLimit uint32 `json:"effect_limit"` + CommitTS uint64 `json:"-"` } func newOperationRecord(id OperationID, intent []byte, resultLimit, effectLimit uint32) operationRecord { owned := append([]byte(nil), intent...) return operationRecord{ - ID: id, - State: OperationPending, - Fingerprint: sha256.Sum256(owned), - Intent: owned, - Reservation: 2*(uint64(len(owned))+uint64(resultLimit)+uint64(effectLimit)) + operationHeaderBytes + operationReservationOverhead, - ResultLimit: resultLimit, - EffectLimit: effectLimit, + ID: id, + State: OperationPending, + Fingerprint: sha256.Sum256(owned), + RecordIsPrimary: true, + Intent: owned, + Reservation: 2*(uint64(len(owned))+uint64(resultLimit)+uint64(effectLimit)) + operationHeaderBytes + operationReservationOverhead, + ResultLimit: resultLimit, + EffectLimit: effectLimit, } } @@ -110,34 +119,56 @@ func decodeOperationRecord(raw []byte) (operationRecord, error) { return operationRecord{}, errors.WithStack(ErrOperationRecordCorrupt) } record.CommitTS = binary.BigEndian.Uint64(raw[operationCommitTSOffset:]) - if record.State != OperationPending && record.CommitTS == 0 { - return operationRecord{}, errors.WithStack(ErrOperationRecordCorrupt) - } - if record.State != OperationState(raw[5]) || validateOperationRecord(record) != nil { + if !validDecodedOperationRecord(raw, record) { return operationRecord{}, errors.WithStack(ErrOperationRecordCorrupt) } return record, nil } +func validDecodedOperationRecord(raw []byte, record operationRecord) bool { + return (raw[4] != operationRecordLegacyVersion || (!record.RecordIsPrimary && len(record.FencedAttempts) == 0)) && + (record.State == OperationPending || record.CommitTS != 0) && + record.State == OperationState(raw[5]) && validateOperationRecord(record) == nil +} + func validOperationHeader(raw []byte) bool { return len(raw) >= operationHeaderBytes && len(raw) <= maxOperationRecordBytes && - bytes.Equal(raw[:4], []byte("FSOP")) && raw[4] == operationRecordVersion && + bytes.Equal(raw[:4], []byte("FSOP")) && + (raw[4] == operationRecordVersion || raw[4] == operationRecordLegacyVersion) && raw[6] == 0 && raw[7] == 0 } func validateOperationRecord(record operationRecord) error { - if !validOperationCore(record) || !validOperationEffects(record) || !validOperationState(record) { + if !validOperationCore(record) || !validOperationEffects(record) || !validOperationState(record) || !validFencedAttempts(record) { return errors.WithStack(ErrOperationRecordCorrupt) } return nil } +func validFencedAttempts(record operationRecord) bool { + if len(record.FencedAttempts) > maxOperationFencedAttempts || (len(record.FencedAttempts) != 0 && !record.RecordIsPrimary) { + return false + } + seen := make(map[[16]byte]struct{}, len(record.FencedAttempts)) + for _, attempt := range record.FencedAttempts { + if attempt == [16]byte{} || attempt == record.AttemptID { + return false + } + if _, duplicate := seen[attempt]; duplicate { + return false + } + seen[attempt] = struct{}{} + } + return true +} + func validOperationCore(record operationRecord) bool { return record.ID.Sequence != 0 && record.ID.Filesystem != [16]byte{} && record.ID.Session != [16]byte{} && record.Fingerprint == sha256.Sum256(record.Intent) && record.Reservation != 0 && record.ResultLimit != 0 && uint64(record.ResultLimit) <= maxOperationRecordBytes && uint64(len(record.Result)) <= uint64(record.ResultLimit) && - record.Reservation == 2*(uint64(len(record.Intent))+uint64(record.ResultLimit)+uint64(record.EffectLimit))+operationHeaderBytes+operationReservationOverhead + record.Reservation == 2*(uint64(len(record.Intent))+uint64(record.ResultLimit)+uint64(record.EffectLimit))+ + operationHeaderBytes+operationReservationOverhead+uint64(len(record.FencedAttempts))*operationFenceReservation } func validOperationEffects(record operationRecord) bool { diff --git a/internal/filesystem/operation_release.go b/internal/filesystem/operation_release.go new file mode 100644 index 000000000..7ff325f89 --- /dev/null +++ b/internal/filesystem/operation_release.go @@ -0,0 +1,156 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + releaseOperationVersion = 1 + releaseResultContention = 2 + releaseOperationResultLimit = 1 + releaseOperationEffectLimit = 64 << 10 +) + +type releaseOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + FH uint64 `json:"fh"` + ClientID []byte `json:"client_id"` +} + +// ReleaseWithOperation makes a handle release terminal under one operation ID. +func (s *Service) ReleaseWithOperation(ctx context.Context, id OperationID, inode, fh uint64, clientID []byte) error { + clientID = bytes.Clone(clientID) + intent, err := json.Marshal(releaseOperationIntent{Version: releaseOperationVersion, Inode: inode, FH: fh, ClientID: clientID}) + if err != nil { + return errors.Wrap(err, "encode release intent") + } + effectLimit, err := releaseEffectLimit(inode, fh, clientID) + if err != nil { + return err + } + journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, releaseOperationResultLimit, effectLimit) + if err != nil { + return err + } + if terminal { + return resolveReleaseOperation(ctx, journal, id) + } + return s.executeReleaseOperation(ctx, journal, id, attempt, inode, fh, clientID, intent) +} + +func releaseEffectLimit(inode, fh uint64, clientID []byte) (uint32, error) { + return operationEffectLimitWithSlack(releaseOperationEffectLimit, fskeys.RefKey(inode, clientID, fh)) +} + +func (s *Service) admitHandleOperation(ctx context.Context, id OperationID, intent []byte, resultLimit, effectLimit uint32) (*OperationJournal, operationRecord, bool, error) { + journal, err := NewOperationJournal(s) + if err != nil { + return nil, operationRecord{}, false, err + } + admitted, err := journal.Admit(ctx, id, intent, resultLimit, effectLimit) + if err != nil { + return nil, operationRecord{}, false, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return journal, operationRecord{}, true, nil + } + attempt, err := journal.BeginAttempt(ctx, id) + return journal, attempt, false, err +} + +func (s *Service) executeReleaseOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, fh uint64, clientID, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishReleaseOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, fh, clientID, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{releaseResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeReleaseOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishReleaseOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, fh uint64, clientID, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, cleanup, err := s.planReleaseOperation(ctx, snapshot, inode, fh, clientID, intent) + if err != nil { + return err + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + err = decodeReleaseOperationResult(record) + } else if !isDefinitiveWriteConflict(err) { + err = resolveReleaseAttempt(ctx, journal, id, attempt) + } + if err == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return err +} + +func (s *Service) planReleaseOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode, fh uint64, clientID, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error) { + refKey := fskeys.RefKey(inode, clientID, fh) + elems, reads, delta, cleanup, _, err := s.releaseRefTxnParts(ctx, snapshot.ReadTS, inode, refKey) + if err != nil { + return operationAttemptPlan{}, nil, err + } + elems, reads, err = s.appendUsageUpdate(ctx, snapshot.ReadTS, elems, reads, delta) + if err != nil { + return operationAttemptPlan{}, nil, err + } + return operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + ReadKeys: reads, Effects: elems, Result: []byte{releaseOperationVersion}}, cleanup, nil +} + +func decodeReleaseOperationResult(record operationRecord) error { + if record.State == OperationCancelled && len(record.Result) == 1 && record.Result[0] == releaseResultContention { + return errors.WithStack(ErrContentionLimit) + } + if record.State != OperationCommitted || len(record.Result) != 1 || record.Result[0] != releaseOperationVersion { + return errors.WithStack(ErrOperationRecordCorrupt) + } + return nil +} + +func resolveReleaseOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeReleaseOperationResult(record) +} + +func resolveReleaseAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && outcome == AttemptCommitted { + return decodeReleaseOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} diff --git a/internal/filesystem/operation_release_test.go b/internal/filesystem/operation_release_test.go new file mode 100644 index 000000000..7e140a084 --- /dev/null +++ b/internal/filesystem/operation_release_test.go @@ -0,0 +1,77 @@ +package filesystem + +import ( + "bytes" + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestReleaseWithOperationSupportsLongClientID(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := bytes.Repeat([]byte("a"), 65536) + openID := testWriteOperationID(t, svc, 1) + fh, err := svc.OpenWithOperation(ctx, openID, file.Inode, clientID) + require.NoError(t, err) + releaseID := openID + releaseID.Sequence = 2 + require.NoError(t, svc.ReleaseWithOperation(ctx, releaseID, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, releaseID)) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.False(t, exists) +} + +func TestReleaseWithOperationDoesNotRemoveNewHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + otherFH, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, otherFH), ts) + require.NoError(t, err) + require.True(t, exists) + _, exists, err = svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.False(t, exists) + // A delayed lease refresh cannot revive the terminal release. + require.ErrorIs(t, svc.RefreshOpenHandleLease(ctx, file.Inode, fh, clientID), ErrNotFound) +} + +func TestReleaseWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.RefKey(file.Inode, clientID, fh), loseAck: true} + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) +} diff --git a/internal/filesystem/operation_rename.go b/internal/filesystem/operation_rename.go new file mode 100644 index 000000000..9711b2e28 --- /dev/null +++ b/internal/filesystem/operation_rename.go @@ -0,0 +1,247 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + renameIntentVersion = 1 + renameEffectLimit = 64 << 10 + renameResultSuccess = 0 + renameResultMissing = 1 + renameResultNotDir = 2 + renameResultIsDir = 3 + renameResultNotEmpty = 4 + renameResultContention = 5 +) + +type renameOperationIntent struct { + Version uint8 `json:"version"` + Parent uint64 `json:"parent"` + OldName []byte `json:"old_name"` + NewName []byte `json:"new_name"` +} + +// RenameWithOperation journals one same-directory rename and its result. +func (s *Service) RenameWithOperation(ctx context.Context, id OperationID, oldParent uint64, oldName []byte, newParent uint64, newName []byte) error { + if oldParent != newParent { + return errors.WithStack(ErrCrossDevice) + } + if err := validateName(oldName); err != nil { + return err + } + if err := validateName(newName); err != nil { + return err + } + oldName = bytes.Clone(oldName) + newName = bytes.Clone(newName) + intent, err := json.Marshal(renameOperationIntent{ + Version: renameIntentVersion, Parent: oldParent, + OldName: oldName, NewName: newName, + }) + if err != nil { + return errors.Wrap(err, "encode rename intent") + } + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + admitted, err := journal.Admit(ctx, id, intent, 1, renameEffectLimit) + if err != nil { + return err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveRenameOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return err + } + return s.executeRenameOperation(ctx, journal, id, attempt, oldParent, oldName, newName, intent) +} + +func (s *Service) executeRenameOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, oldName, newName, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishRenameOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, oldName, newName, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{renameResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeRenameOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishRenameOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, oldName, newName, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, cleanup, semanticErr, err := s.planRenameOperation(ctx, snapshot, parent, oldName, newName, intent) + if err != nil { + return err + } + if semanticErr != nil { + return rejectRenameOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + err = decodeRenameOperationResult(record) + } else if !isDefinitiveWriteConflict(err) { + err = resolveRenameAttempt(ctx, journal, id, attempt) + } + if err == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return err +} + +func (s *Service) planRenameOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, oldName, newName, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + ts := snapshot.ReadTS + oldKey := fskeys.DirEntryKey(parent, oldName) + newKey := fskeys.DirEntryKey(parent, newName) + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(parent), oldKey, newKey}} + parentMeta, semanticErr, err := s.unlinkParentAt(ctx, parent, ts) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if semanticErr != nil { + return plan, nil, semanticErr, nil + } + oldEntry, err := s.dirEntryAt(ctx, parent, oldName, ts) + if errors.Is(err, ErrNotFound) { + return plan, nil, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if bytes.Equal(oldName, newName) { + plan.Result = []byte{renameResultSuccess} + return plan, nil, nil, nil + } + replacedMeta, err := s.renameReplacedFileMeta(ctx, parent, newName, oldEntry, ts) + if err != nil { + return plan, nil, err, nil + } + return s.planRenameEffects(ctx, plan, parentMeta, oldEntry, replacedMeta, parent, oldKey, newKey) +} + +func (s *Service) planRenameEffects(ctx context.Context, plan operationAttemptPlan, parentMeta *InodeMeta, oldEntry DirEntry, replaced *InodeMeta, parent uint64, oldKey, newKey []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + now := s.now().UnixNano() + parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now + putNew, err := putElem(newKey, DirEntry{Inode: oldEntry.Inode, Type: oldEntry.Type}) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + putVersion, err := putElem(fskeys.DirVersionKey(parent), unixNsecVersion(now)) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = []*kv.Elem[kv.OP]{{Op: kv.Del, Key: oldKey}, putNew, putVersion} + targetElems, targetReads, delta, cleanup, err := s.renameTargetTxnParts(ctx, plan.ReadTS, parentMeta, replaced, now) + if errors.Is(err, ErrNotEmpty) { + if replaced != nil { + plan.ReadKeys = append(plan.ReadKeys, fskeys.DirVersionKey(replaced.Inode)) + } + return plan, nil, ErrNotEmpty, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append(plan.Effects, targetElems...) + plan.ReadKeys = append(plan.ReadKeys, targetReads...) + putParent, err := putElem(fskeys.InodeKey(parent), parentMeta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append(plan.Effects, putParent) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, plan.ReadTS, plan.Effects, plan.ReadKeys, delta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Result = []byte{renameResultSuccess} + return plan, cleanup, nil, nil +} + +func decodeRenameOperationResult(record operationRecord) error { + if len(record.Result) != 1 { + return errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCommitted && record.Result[0] == renameResultSuccess { + return nil + } + if record.State == OperationCancelled { + return cancelledOperationError(record.Result[0], renameResultContention) + } + if record.State != OperationRejected { + return errors.WithStack(ErrOperationRecordCorrupt) + } + switch record.Result[0] { + case renameResultMissing: + return errors.WithStack(ErrNotFound) + case renameResultNotDir: + return errors.WithStack(ErrNotDir) + case renameResultIsDir: + return errors.WithStack(ErrIsDir) + case renameResultNotEmpty: + return errors.WithStack(ErrNotEmpty) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +func resolveRenameOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeRenameOperationResult(record) +} + +func resolveRenameAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeRenameOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectRenameOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) error { + var status byte + switch { + case errors.Is(semanticErr, ErrNotFound): + status = renameResultMissing + case errors.Is(semanticErr, ErrNotDir): + status = renameResultNotDir + case errors.Is(semanticErr, ErrIsDir): + status = renameResultIsDir + case errors.Is(semanticErr, ErrNotEmpty): + status = renameResultNotEmpty + default: + return semanticErr + } + return rejectNamespaceOperation(ctx, journal, id, attempt, plan, status, decodeRenameOperationResult) +} diff --git a/internal/filesystem/operation_rename_test.go b/internal/filesystem/operation_rename_test.go new file mode 100644 index 000000000..ad966e74f --- /dev/null +++ b/internal/filesystem/operation_rename_test.go @@ -0,0 +1,52 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenameWithOperationDoesNotMoveRecreatedSource(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + old, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target"))) + newFile, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target"))) + source, err := svc.Resolve(ctx, RootInode, []byte("source")) + require.NoError(t, err) + require.Equal(t, newFile.Inode, source) + target, err := svc.Resolve(ctx, RootInode, []byte("target")) + require.NoError(t, err) + require.Equal(t, old.Inode, target) +} + +func TestRenameWithOperationRetainsMissingSource(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + require.ErrorIs(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target")), ErrNotFound) + file, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.ErrorIs(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target")), ErrNotFound) + source, err := svc.Resolve(ctx, RootInode, []byte("source")) + require.NoError(t, err) + require.Equal(t, file.Inode, source) +} + +func TestRenameWithOperationNoopIsRetained(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + _, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("file"), RootInode, []byte("file"))) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("file"), RootInode, []byte("file"))) +} diff --git a/internal/filesystem/operation_setattr.go b/internal/filesystem/operation_setattr.go new file mode 100644 index 000000000..8ef43fe02 --- /dev/null +++ b/internal/filesystem/operation_setattr.go @@ -0,0 +1,319 @@ +package filesystem + +import ( + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + setAttrOperationVersion = 1 + setAttrOperationResultLimit = 512 + setAttrOperationEffectLimit = 256 + setAttrSizeEffectLimit = 64 << 10 + setAttrResultNotFound = 1 + setAttrResultIsDir = 2 + setAttrResultContention = 3 +) + +type setAttrOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + Mask SetAttrMask `json:"mask"` + Attrs SetAttr `json:"attrs"` +} + +type setAttrOperationResult struct { + Version uint8 `json:"version"` + Stat Stat `json:"stat"` + Error uint8 `json:"error"` +} + +// SetAttrWithOperation journals attribute changes and retains the original Stat. +func (s *Service) SetAttrWithOperation(ctx context.Context, id OperationID, inode uint64, mask SetAttrMask, attrs SetAttr) (Stat, error) { + intent, err := json.Marshal(setAttrOperationIntent{Version: setAttrOperationVersion, Inode: inode, Mask: mask, Attrs: attrs}) + if err != nil { + return Stat{}, errors.Wrap(err, "encode setattr intent") + } + journal, err := NewOperationJournal(s) + if err != nil { + return Stat{}, err + } + effectLimit := uint32(setAttrOperationEffectLimit) + if mask.Size { + effectLimit = setAttrSizeEffectLimit + } + admitted, err := journal.Admit(ctx, id, intent, setAttrOperationResultLimit, effectLimit) + if err != nil { + return Stat{}, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveSetAttrOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return Stat{}, err + } + return s.executeSetAttrOperation(ctx, journal, id, attempt, inode, mask, attrs, intent) +} + +func (s *Service) executeSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (Stat, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + stat, err := s.publishSetAttrOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, mask, attrs, intent) + if !isDefinitiveWriteConflict(err) { + return stat, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return Stat{}, err + } + if retry == writeConflictRetryLimit-1 { + result, marshalErr := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: setAttrResultContention}) + if marshalErr != nil { + return Stat{}, errors.Wrap(marshalErr, "encode setattr contention") + } + record, cancelErr := cancelOperationContention(ctx, journal, id, result) + if cancelErr != nil { + return Stat{}, cancelErr + } + return decodeSetAttrOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return Stat{}, err + } + } + return Stat{}, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishSetAttrOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (Stat, error) { + plan, cleanup, semanticErr, err := s.prepareSetAttrOperationPlan(ctx, journal, id, attempt, inode, mask, attrs, intent) + if err != nil { + return Stat{}, err + } + if semanticErr != nil { + return rejectSetAttrOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + stat, decodeErr := decodeSetAttrOperationResult(record) + if decodeErr == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return stat, decodeErr + } + if !isDefinitiveWriteConflict(err) { + stat, resolveErr := resolveSetAttrAttempt(ctx, journal, id, attempt) + if resolveErr == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return stat, resolveErr + } + return Stat{}, err +} + +func (s *Service) prepareSetAttrOperationPlan(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + for retry := range writeConflictRetryLimit { + cleaned, err := s.prepareSetAttrCleanup(ctx, inode, mask.Size) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan, cleanup, semanticErr, err := s.planSetAttrOperation(ctx, snapshot, cleaned, inode, mask, attrs, intent) + if errors.Is(err, errWriteCleanupChanged) { + if err = waitWriteConflictRetry(ctx, retry); err != nil { + return operationAttemptPlan{}, nil, nil, err + } + continue + } + return plan, cleanup, semanticErr, err + } + return operationAttemptPlan{}, nil, nil, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) prepareSetAttrCleanup(ctx context.Context, inode uint64, sizeChange bool) (*InodeMeta, error) { + if !sizeChange { + return nil, nil + } + ts, err := s.readTS(ctx) + if err != nil { + return nil, err + } + meta, err := s.inodeAt(ctx, inode, ts) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil || meta.Type == TypeDirectory { + return meta, err + } + if err = s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return nil, err + } + _, cleaned, _, err := s.cleanupStaleChunksBeforeSizeChange(ctx, inode, meta) + return cleaned, err +} + +func (s *Service) planSetAttrOperation(ctx context.Context, snapshot operationPlanningSnapshot, cleaned *InodeMeta, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(inode), fskeys.HomeKey(inode)}} + meta, err := s.inodeAt(ctx, inode, snapshot.ReadTS) + if errors.Is(err, ErrNotFound) { + return plan, nil, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + oldSize := meta.Size + var cleanup *chunkDeletePlan + if mask.Size { + cleanup, err = s.planSetAttrSize(ctx, &plan, meta, cleaned, attrs.Size) + if errors.Is(err, ErrIsDir) { + return plan, nil, ErrIsDir, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + } + now := s.now().UnixNano() + applySetAttrFields(meta, mask, attrs, oldSize, now) + meta.CtimeNsec = now + elem, err := putElem(fskeys.InodeKey(inode), meta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append(plan.Effects, elem) + plan.Result, err = json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Stat: meta.Stat()}) + return plan, cleanup, nil, errors.Wrap(err, "encode setattr result") +} + +func (s *Service) planSetAttrSize(ctx context.Context, plan *operationAttemptPlan, meta, cleaned *InodeMeta, size uint64) (*chunkDeletePlan, error) { + if meta.Type == TypeDirectory { + return nil, ErrIsDir + } + if err := s.ensureActiveHomeAt(ctx, meta, plan.ReadTS); err != nil { + return nil, err + } + if cleaned == nil || (size > meta.Size && (meta.Size != cleaned.Size || meta.HomeSlot != cleaned.HomeSlot)) { + return nil, errWriteCleanupChanged + } + if size < meta.Size { + elems, reads, delta, cleanup, err := s.truncateChunkElems(ctx, meta, plan.ReadTS, size, meta.effectiveChunkSize(s.chunkSize)) + if err != nil { + return nil, err + } + plan.Effects = append(plan.Effects, elems...) + plan.ReadKeys = append(plan.ReadKeys, reads...) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, plan.ReadTS, plan.Effects, plan.ReadKeys, delta) + if err != nil { + return nil, err + } + meta.Size = size + return cleanup, nil + } + meta.Size = size + return nil, nil +} + +func applySetAttrFields(meta *InodeMeta, mask SetAttrMask, attrs SetAttr, oldSize uint64, now int64) { + if mask.Mode { + meta.Mode = attrs.Mode + } + if mask.UID { + meta.UID = attrs.UID + } + if mask.GID { + meta.GID = attrs.GID + } + if mask.Atime { + meta.AtimeNsec = attrs.AtimeNsec + } + if mask.Mtime { + meta.MtimeNsec = attrs.MtimeNsec + } else if mask.Size && attrs.Size != oldSize { + meta.MtimeNsec = now + } +} + +func decodeSetAttrOperationResult(record operationRecord) (Stat, error) { + var result setAttrOperationResult + if len(record.Result) == 0 || len(record.Result) > setAttrOperationResultLimit { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if err := json.Unmarshal(record.Result, &result); err != nil { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if result.Version != setAttrOperationVersion { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected { + return Stat{}, rejectedSetAttrError(result.Error) + } + if record.State == OperationCancelled { + return Stat{}, cancelledOperationError(result.Error, setAttrResultContention) + } + if record.State != OperationCommitted || result.Error != 0 || result.Stat.Inode == 0 { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + return result.Stat, nil +} + +func rejectedSetAttrError(status uint8) error { + switch status { + case setAttrResultNotFound: + return errors.WithStack(ErrNotFound) + case setAttrResultIsDir: + return errors.WithStack(ErrIsDir) + default: + return errors.WithStack(ErrOperationRecordCorrupt) + } +} + +func resolveSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID) (Stat, error) { + record, err := journal.Resolve(ctx, id) + if err != nil { + if errors.Is(err, ErrOperationPending) { + return Stat{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + return Stat{}, err + } + return decodeSetAttrOperationResult(record) +} + +func resolveSetAttrAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (Stat, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeSetAttrOperationResult(record) + } + return Stat{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (Stat, error) { + status := uint8(setAttrResultNotFound) + if errors.Is(semanticErr, ErrIsDir) { + status = setAttrResultIsDir + } else if !errors.Is(semanticErr, ErrNotFound) { + return Stat{}, semanticErr + } + result, err := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: status}) + if err != nil { + return Stat{}, errors.Wrap(err, "encode setattr rejection") + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) + if err != nil { + if isDefinitiveWriteConflict(err) { + return Stat{}, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { + return Stat{}, err + } + return resolveSetAttrAttempt(ctx, journal, id, attempt) + } + return decodeSetAttrOperationResult(record) +} diff --git a/internal/filesystem/operation_setattr_test.go b/internal/filesystem/operation_setattr_test.go new file mode 100644 index 000000000..3c6eaf53b --- /dev/null +++ b/internal/filesystem/operation_setattr_test.go @@ -0,0 +1,106 @@ +package filesystem + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetAttrWithOperationRetainsOriginalStat(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + mask := SetAttrMask{Mode: true, UID: true} + attrs := SetAttr{Mode: 0o600, UID: 42} + original, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.EqualValues(t, 0o600, original.Mode) + require.EqualValues(t, 42, original.UID) + _, err = svc.SetAttr(ctx, file.Inode, SetAttrMask{Mode: true}, SetAttr{Mode: 0o644}) + require.NoError(t, err) + replayed, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.Equal(t, original, replayed) + current, err := svc.GetAttr(ctx, file.Inode) + require.NoError(t, err) + require.EqualValues(t, 0o644, current.Mode) + _, err = svc.SetAttrWithOperation(ctx, id, file.Inode, mask, SetAttr{Mode: 0o640, UID: 42}) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestSetAttrWithOperationRetainsMissingInode(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err := svc.SetAttrWithOperation(ctx, id, 999, SetAttrMask{Mode: true}, SetAttr{Mode: 0o600}) + require.ErrorIs(t, err, ErrNotFound) + } +} + +func TestSetAttrWithOperationDoesNotRepeatShrinkAfterExtension(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + _, err = svc.Write(ctx, file.Inode, 0, 0, []byte("abcdefghijkl")) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + mask := SetAttrMask{Size: true, Mode: true} + attrs := SetAttr{Size: 4, Mode: 0o600} + original, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.EqualValues(t, 4, original.Size) + require.EqualValues(t, 0o600, original.Mode) + _, err = svc.Write(ctx, file.Inode, 0, 4, []byte("xyz")) + require.NoError(t, err) + replayed, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.Equal(t, original, replayed) + current, err := svc.GetAttr(ctx, file.Inode) + require.NoError(t, err) + require.EqualValues(t, 7, current.Size) + data, err := svc.Read(ctx, file.Inode, 0, 0, 7) + require.NoError(t, err) + require.Equal(t, []byte("abcdxyz"), data) +} + +func TestSetAttrWithOperationRetainsDirectorySizeRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err := svc.SetAttrWithOperation(ctx, id, RootInode, SetAttrMask{Size: true}, SetAttr{Size: 1}) + require.ErrorIs(t, err, ErrIsDir) + } +} + +func TestSetAttrWithOperationFinishesPagedChunkDeletion(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + data := bytes.Repeat([]byte("x"), (chunkDeleteTxnPageSize+5)*int(testChunkSize)) + _, err = svc.Write(ctx, file.Inode, 0, 0, data) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + stat, err := svc.SetAttrWithOperation(ctx, id, file.Inode, SetAttrMask{Size: true}, SetAttr{Size: 0}) + require.NoError(t, err) + require.Zero(t, stat.Size) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + meta, err := svc.inodeAt(ctx, file.Inode, ts) + require.NoError(t, err) + page, err := svc.scanExistingChunkPage(ctx, meta.HomeSlot, file.Inode, ts, nil, chunkDeleteTxnPageSize+1) + require.NoError(t, err) + require.Empty(t, page) +} diff --git a/internal/filesystem/operation_unlink.go b/internal/filesystem/operation_unlink.go new file mode 100644 index 000000000..49868b107 --- /dev/null +++ b/internal/filesystem/operation_unlink.go @@ -0,0 +1,313 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/binary" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + unlinkIntentVersion = 1 + unlinkResultSuccess = 0 + unlinkResultMissing = 1 + unlinkResultNotDir = 2 + unlinkResultIsDir = 3 + unlinkResultNotEmpty = 4 + unlinkResultContention = 5 + unlinkEffectLimit = 64 << 10 + unlinkIntentHeader = 10 +) + +// UnlinkWithOperation records one file removal and its result under id. +// Directory removal has a separate emptiness predicate and planner. +func (s *Service) UnlinkWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte) error { + return s.unlinkNodeWithOperation(ctx, id, parent, name, false) +} + +// RmdirWithOperation journals an empty-directory removal and its result. +func (s *Service) RmdirWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte) error { + return s.unlinkNodeWithOperation(ctx, id, parent, name, true) +} + +func (s *Service) unlinkNodeWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, directory bool) error { + if err := validateName(name); err != nil { + return err + } + name = bytes.Clone(name) + intent := make([]byte, unlinkIntentHeader+len(name)) + intent[0] = unlinkIntentVersion + binary.BigEndian.PutUint64(intent[1:9], parent) + if directory { + intent[9] = 1 + } + copy(intent[unlinkIntentHeader:], name) + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + admitted, err := journal.Admit(ctx, id, intent, 1, unlinkEffectLimit) + if err != nil { + return err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveUnlinkOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return err + } + return s.executeUnlinkOperation(ctx, journal, id, attempt, parent, name, directory, intent) +} + +func (s *Service) executeUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name []byte, directory bool, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishUnlinkOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, name, directory, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{unlinkResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeUnlinkOperationResult(record) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishUnlinkOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, name []byte, directory bool, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, cleanup, semanticErr, err := s.planUnlinkOperation(ctx, snapshot, parent, name, directory, intent) + if err != nil { + return err + } + if semanticErr != nil { + return rejectUnlinkOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + err = decodeUnlinkOperationResult(record) + } else if !isDefinitiveWriteConflict(err) { + err = resolveUnlinkAttempt(ctx, journal, id, attempt) + } + if err == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return err +} + +func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, name []byte, directory bool, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + ts := snapshot.ReadTS + entryKey := fskeys.DirEntryKey(parent, name) + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(parent), entryKey}} + parentMeta, semanticErr, err := s.unlinkParentAt(ctx, parent, ts) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if semanticErr != nil { + return plan, nil, semanticErr, nil + } + meta, targetKey, semanticErr, err := s.unlinkTargetAt(ctx, parent, name, ts, directory) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if len(targetKey) != 0 { + plan.ReadKeys = append(plan.ReadKeys, targetKey) + } + appendUnlinkDirectoryPredicate(&plan, meta, directory, semanticErr) + if semanticErr != nil { + return plan, nil, semanticErr, nil + } + now := s.now().UnixNano() + parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now + nodeElems, nodeReads, delta, cleanup, err := s.unlinkNodeTxnParts(ctx, ts, parentMeta, meta, directory, now) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + parentEffects, err := unlinkParentEffects(parent, parentMeta, now) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append([]*kv.Elem[kv.OP]{{Op: kv.Del, Key: entryKey}}, nodeElems...) + plan.Effects = append(plan.Effects, parentEffects...) + plan.ReadKeys = append(plan.ReadKeys, nodeReads...) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, ts, plan.Effects, plan.ReadKeys, delta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Result = []byte{unlinkResultSuccess} + return plan, cleanup, nil, nil +} + +func appendUnlinkDirectoryPredicate(plan *operationAttemptPlan, meta *InodeMeta, directory bool, semanticErr error) { + if directory && meta != nil && semanticErr != nil { + plan.ReadKeys = append(plan.ReadKeys, fskeys.DirVersionKey(meta.Inode)) + } +} + +func (s *Service) unlinkNodeTxnParts(ctx context.Context, ts uint64, parent, target *InodeMeta, directory bool, now int64) ([]*kv.Elem[kv.OP], [][]byte, usageDelta, *chunkDeletePlan, error) { + if !directory { + return s.unlinkFileTxnParts(ctx, ts, target, now) + } + if parent.Nlink > fileInitialNlink { + parent.Nlink-- + } + keys := [][]byte{fskeys.InodeKey(target.Inode), fskeys.HomeKey(target.Inode), fskeys.DirVersionKey(target.Inode)} + effects := make([]*kv.Elem[kv.OP], 0, len(keys)) + for _, key := range keys { + effects = append(effects, &kv.Elem[kv.OP]{Op: kv.Del, Key: key}) + } + return effects, keys[1:], usageDelta{filesSub: 1}, nil, nil +} + +func unlinkParentEffects(parent uint64, meta *InodeMeta, now int64) ([]*kv.Elem[kv.OP], error) { + parentElem, err := putElem(fskeys.InodeKey(parent), meta) + if err != nil { + return nil, err + } + versionElem, err := putElem(fskeys.DirVersionKey(parent), unixNsecVersion(now)) + if err != nil { + return nil, err + } + return []*kv.Elem[kv.OP]{parentElem, versionElem}, nil +} + +func (s *Service) unlinkParentAt(ctx context.Context, parent, ts uint64) (*InodeMeta, error, error) { + meta, err := s.inodeAt(ctx, parent, ts) + if errors.Is(err, ErrNotFound) { + return nil, ErrNotFound, nil + } + if err != nil { + return nil, nil, err + } + if meta.Type != TypeDirectory { + return nil, ErrNotDir, nil + } + return meta, nil, nil +} + +func (s *Service) unlinkTargetAt(ctx context.Context, parent uint64, name []byte, ts uint64, directory bool) (*InodeMeta, []byte, error, error) { + entry, err := s.dirEntryAt(ctx, parent, name, ts) + if errors.Is(err, ErrNotFound) { + return nil, nil, ErrNotFound, nil + } + if err != nil { + return nil, nil, nil, err + } + targetKey := fskeys.InodeKey(entry.Inode) + meta, err := s.inodeAt(ctx, entry.Inode, ts) + if errors.Is(err, ErrNotFound) { + return nil, targetKey, ErrNotFound, nil + } + if err != nil { + return nil, nil, nil, err + } + semanticErr, err := s.validateUnlinkTarget(ctx, meta, ts, directory) + if err != nil { + return nil, nil, nil, err + } + if semanticErr != nil { + return meta, targetKey, semanticErr, nil + } + return meta, targetKey, nil, nil +} + +func (s *Service) validateUnlinkTarget(ctx context.Context, meta *InodeMeta, ts uint64, directory bool) (error, error) { + if directory && meta.Type != TypeDirectory { + return ErrNotDir, nil + } + if !directory && meta.Type == TypeDirectory { + return ErrIsDir, nil + } + if directory { + if err := s.ensureDirectoryEmpty(ctx, meta.Inode, ts); err != nil { + if errors.Is(err, ErrNotEmpty) { + return ErrNotEmpty, nil + } + return nil, err + } + } else if err := s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return nil, err + } + return nil, nil +} + +func decodeUnlinkOperationResult(record operationRecord) error { + if len(record.Result) != 1 { + return errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCommitted && record.Result[0] == unlinkResultSuccess { + return nil + } + if record.State == OperationCancelled { + return cancelledOperationError(record.Result[0], unlinkResultContention) + } + if record.State != OperationRejected { + return errors.WithStack(ErrOperationRecordCorrupt) + } + switch record.Result[0] { + case unlinkResultMissing: + return errors.WithStack(ErrNotFound) + case unlinkResultNotDir: + return errors.WithStack(ErrNotDir) + case unlinkResultIsDir: + return errors.WithStack(ErrIsDir) + case unlinkResultNotEmpty: + return errors.WithStack(ErrNotEmpty) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +func resolveUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeUnlinkOperationResult(record) +} + +func resolveUnlinkAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeUnlinkOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) error { + var status byte + switch { + case errors.Is(semanticErr, ErrNotFound): + status = unlinkResultMissing + case errors.Is(semanticErr, ErrNotDir): + status = unlinkResultNotDir + case errors.Is(semanticErr, ErrIsDir): + status = unlinkResultIsDir + case errors.Is(semanticErr, ErrNotEmpty): + status = unlinkResultNotEmpty + default: + return semanticErr + } + return rejectNamespaceOperation(ctx, journal, id, attempt, plan, status, decodeUnlinkOperationResult) +} diff --git a/internal/filesystem/operation_unlink_test.go b/internal/filesystem/operation_unlink_test.go new file mode 100644 index 000000000..3a9e5379e --- /dev/null +++ b/internal/filesystem/operation_unlink_test.go @@ -0,0 +1,85 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnlinkWithOperationDoesNotRemoveRecreatedName(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + old, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file"))) + newFile, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.NotEqual(t, old.Inode, newFile.Inode) + require.NoError(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file"))) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, newFile.Inode, resolved) + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("other")), ErrOperationIDReused) +} + +func TestUnlinkWithOperationRetainsMissingName(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file")), ErrNotFound) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file")), ErrNotFound) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, file.Inode, resolved) +} + +func TestUnlinkWithOperationRetainsDirectoryRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + _, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + for range 2 { + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("dir")), ErrIsDir) + } +} + +func TestRmdirWithOperationDoesNotRemoveRecreatedDirectory(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + old, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir"))) + newDir, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + require.NotEqual(t, old.Inode, newDir.Inode) + require.NoError(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir"))) + resolved, err := svc.Resolve(ctx, RootInode, []byte("dir")) + require.NoError(t, err) + require.Equal(t, newDir.Inode, resolved) +} + +func TestRmdirWithOperationRetainsNotEmptyRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + dir, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + _, err = svc.Create(ctx, dir.Inode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.ErrorIs(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir")), ErrNotEmpty) + require.NoError(t, svc.Unlink(ctx, dir.Inode, []byte("file"))) + require.ErrorIs(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir")), ErrNotEmpty) + _, err = svc.Resolve(ctx, RootInode, []byte("dir")) + require.NoError(t, err) +} diff --git a/internal/filesystem/operation_write.go b/internal/filesystem/operation_write.go new file mode 100644 index 000000000..08e97434c --- /dev/null +++ b/internal/filesystem/operation_write.go @@ -0,0 +1,374 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/binary" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + writeOperationIntentVersion = 1 + writeOperationIntentHeader = 25 + writeOperationResultBytes = 9 + writeOperationMaxEffectLimit = 1 << 20 + writeOperationEffectSlack = 256 + writeOperationMaxData = 4 << 20 + writeResultSuccess = 0 + writeResultIsDir = 1 + writeResultNotFound = 2 + writeResultPlanQuota = 3 + writeResultPlanContention = 4 + writeResultPlanInvalid = 5 + writeResultPlanStaleHome = 6 + writeResultPlanCancelled = 7 + writeResultPlanDeadline = 8 + writeResultPlanFailed = 9 +) + +var ( + errWriteCleanupChanged = errors.New("filesystem: write cleanup snapshot changed") + ErrWritePlanningFailed = errors.New("filesystem: write planning failed") +) + +// WriteWithOperation publishes one fixed-offset write and its original byte +// count under id. A pending duplicate is resolved without issuing a new write. +// The caller owns acknowledgement after it has attempted the FUSE reply. +func (s *Service) WriteWithOperation(ctx context.Context, id OperationID, inode, offset uint64, data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + if len(data) > writeOperationMaxData { + return 0, errors.WithStack(ErrOperationQuota) + } + end, overflow := addUint64(offset, uint64(len(data))) + if overflow { + return 0, errors.WithStack(ErrInvalid) + } + data = bytes.Clone(data) + intent := encodeWriteOperationIntent(inode, offset, data) + effectLimit, err := s.writeOperationEffectLimit(ctx, inode, offset, end) + if err != nil { + return 0, err + } + journal, err := NewOperationJournal(s) + if err != nil { + return 0, err + } + admitted, err := journal.Admit(ctx, id, intent, writeOperationResultBytes, effectLimit) + if err != nil { + return 0, err + } + if admitted.State != OperationPending { + return resolveWriteOperation(ctx, journal, id) + } + if admitted.AttemptID != [16]byte{} { + return resolveWriteOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return 0, err + } + return s.executeWriteOperation(ctx, journal, id, attempt, inode, offset, end, data, intent) +} + +func (s *Service) writeOperationEffectLimit(ctx context.Context, inode, offset, end uint64) (uint32, error) { + ts, err := s.readTS(ctx) + if err != nil { + return 0, err + } + chunkSize := s.chunkSize + homeSlot := inode + meta, err := s.inodeAt(ctx, inode, ts) + if err == nil { + chunkSize = meta.effectiveChunkSize(s.chunkSize) + homeSlot = meta.HomeSlot + } else if !errors.Is(err, ErrNotFound) { + return 0, err + } + chunks := (end-1)/chunkSize - offset/chunkSize + 1 + if chunks > maxOperationEffectKeys-2 { + return 0, errors.WithStack(ErrOperationQuota) + } + chunkKey := fskeys.ChunkKey(homeSlot, inode, 0) + usageKey := fskeys.UsageRouteKey(kv.RouteKey(chunkKey)) + limit := chunks*uint64(len(chunkKey)) + uint64(len(fskeys.InodeKey(inode))+len(usageKey)+writeOperationEffectSlack) + if limit > writeOperationMaxEffectLimit { + return 0, errors.WithStack(ErrOperationQuota) + } + return uint32(limit), nil +} + +func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, offset, end uint64, data, intent []byte) (int, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + if err := deadlineCtx.Err(); err != nil { + return 0, errors.WithStack(err) + } + result, err := s.publishWriteOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, offset, end, data, intent) + if !isDefinitiveWriteConflict(err) { + return result, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return 0, err + } + if retry == writeConflictRetryLimit-1 { + return cancelWritePlanningFailure(ctx, journal, id, ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return 0, err + } + } + return 0, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishWriteOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, offset, end uint64, data, intent []byte) (int, error) { + plan, semanticErr, err := s.planJournalWrite(ctx, journal, id, attempt, inode, offset, end, data, intent) + if err != nil { + if isDefinitiveWriteConflict(err) { + return 0, err + } + return cancelWritePlanningFailure(ctx, journal, id, err) + } + if semanticErr != nil { + return publishWriteRejection(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeWriteOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveWriteAttempt(ctx, journal, id, attempt) + } + return 0, err +} + +func cancelWritePlanningFailure(ctx context.Context, journal *OperationJournal, id OperationID, planningErr error) (int, error) { + result := encodeWriteOperationResult(0, writePlanningFailureStatus(planningErr)) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), writeConflictRetryBudget) + defer cancel() + for range operationCASRetries { + record, err := journal.CancelWithResult(cleanupCtx, id, result) + if err == nil { + return decodeWriteOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + break + } + } + record, err := journal.Resolve(cleanupCtx, id) + if err == nil { + return decodeWriteOperationResult(record) + } + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func writePlanningFailureStatus(err error) byte { + switch { + case errors.Is(err, ErrOperationQuota): + return writeResultPlanQuota + case errors.Is(err, ErrContentionLimit): + return writeResultPlanContention + case errors.Is(err, ErrInvalid): + return writeResultPlanInvalid + case errors.Is(err, ErrStaleHome): + return writeResultPlanStaleHome + case errors.Is(err, context.Canceled): + return writeResultPlanCancelled + case errors.Is(err, context.DeadlineExceeded): + return writeResultPlanDeadline + default: + return writeResultPlanFailed + } +} + +func encodeWriteOperationIntent(inode, offset uint64, data []byte) []byte { + intent := make([]byte, writeOperationIntentHeader+len(data)) + intent[0] = writeOperationIntentVersion + binary.BigEndian.PutUint64(intent[1:9], inode) + binary.BigEndian.PutUint64(intent[9:17], offset) + binary.BigEndian.PutUint64(intent[17:25], uint64(len(data))) + copy(intent[writeOperationIntentHeader:], data) + return intent +} + +func encodeWriteOperationResult(count uint64, status byte) []byte { + result := make([]byte, writeOperationResultBytes) + result[0] = status + binary.BigEndian.PutUint64(result[1:], count) + return result +} + +func decodeWriteOperationResult(record operationRecord) (int, error) { + if len(record.Result) != writeOperationResultBytes { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCancelled { + return decodeCancelledWriteResult(record.Result[0]) + } + switch record.Result[0] { + case writeResultSuccess: + if record.State != OperationCommitted { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return checkedInt(binary.BigEndian.Uint64(record.Result[1:])) + case writeResultIsDir: + if record.State != OperationRejected { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return 0, errors.WithStack(ErrIsDir) + case writeResultNotFound: + if record.State != OperationRejected { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return 0, errors.WithStack(ErrNotFound) + default: + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } +} + +func decodeCancelledWriteResult(status byte) (int, error) { + if status < writeResultPlanQuota || status > writeResultPlanFailed { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return 0, writePlanningFailureError(status) +} + +func writePlanningFailureError(status byte) error { + switch status { + case writeResultPlanQuota: + return errors.WithStack(ErrOperationQuota) + case writeResultPlanContention: + return errors.WithStack(ErrContentionLimit) + case writeResultPlanInvalid: + return errors.WithStack(ErrInvalid) + case writeResultPlanStaleHome: + return errors.WithStack(ErrStaleHome) + case writeResultPlanCancelled: + return errors.WithStack(context.Canceled) + case writeResultPlanDeadline: + return errors.WithStack(context.DeadlineExceeded) + default: + return errors.WithStack(ErrWritePlanningFailed) + } +} + +func resolveWriteOperation(ctx context.Context, journal *OperationJournal, id OperationID) (int, error) { + record, err := journal.Resolve(ctx, id) + if err != nil { + if errors.Is(err, ErrOperationPending) { + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + return 0, err + } + return decodeWriteOperationResult(record) +} + +func resolveWriteAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (int, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeWriteOperationResult(record) + } + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func publishWriteRejection(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (int, error) { + var status byte + switch { + case errors.Is(semanticErr, ErrIsDir): + status = writeResultIsDir + case errors.Is(semanticErr, ErrNotFound): + status = writeResultNotFound + default: + return 0, semanticErr + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + encodeWriteOperationResult(0, status), plan.ReadKeys) + if err != nil { + if isDefinitiveWriteConflict(err) { + return 0, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { + return 0, err + } + return resolveWriteAttempt(ctx, journal, id, attempt) + } + return decodeWriteOperationResult(record) +} + +func (s *Service) planJournalWrite(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, offset, end uint64, data, intent []byte) (operationAttemptPlan, error, error) { + for retry := range writeConflictRetryLimit { + cleaned, err := s.prepareWriteCleanup(ctx, inode, end) + if err != nil { + return operationAttemptPlan{}, nil, err + } + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan, semanticErr, err := s.planJournalWriteAt(ctx, snapshot, cleaned, inode, offset, end, data, intent) + if errors.Is(err, errWriteCleanupChanged) { + if err = waitWriteConflictRetry(ctx, retry); err != nil { + return operationAttemptPlan{}, nil, err + } + continue + } + return plan, semanticErr, err + } + return operationAttemptPlan{}, nil, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) planJournalWriteAt(ctx context.Context, snapshot operationPlanningSnapshot, cleaned *InodeMeta, inode, offset, end uint64, data, intent []byte) (operationAttemptPlan, error, error) { + plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion} + meta, err := s.inodeAt(ctx, inode, snapshot.ReadTS) + if errors.Is(err, ErrNotFound) { + plan.ReadKeys = [][]byte{fskeys.InodeKey(inode)} + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + if meta.Type == TypeDirectory { + plan.ReadKeys = [][]byte{fskeys.InodeKey(inode)} + return plan, ErrIsDir, nil + } + if err = s.ensureActiveHomeAt(ctx, meta, snapshot.ReadTS); err != nil { + return operationAttemptPlan{}, nil, err + } + if end > meta.Size && (meta.Size != cleaned.Size || meta.HomeSlot != cleaned.HomeSlot) { + return operationAttemptPlan{}, nil, errWriteCleanupChanged + } + plan.Effects, plan.ReadKeys, err = s.planWriteTxn(ctx, meta, snapshot.ReadTS, offset, end, data) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Result = encodeWriteOperationResult(uint64(len(data)), writeResultSuccess) + return plan, nil, nil +} + +func (s *Service) prepareWriteCleanup(ctx context.Context, inode, end uint64) (*InodeMeta, error) { + ts, err := s.readTS(ctx) + if err != nil { + return nil, err + } + meta, err := s.inodeAt(ctx, inode, ts) + if errors.Is(err, ErrNotFound) { + return &InodeMeta{}, nil + } + if err != nil { + return nil, err + } + if meta.Type == TypeDirectory || end <= meta.Size { + return meta, nil + } + if err = s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return nil, err + } + _, cleaned, _, err := s.cleanupStaleChunksBeforeSizeChange(ctx, inode, meta) + return cleaned, err +} diff --git a/internal/filesystem/operation_write_test.go b/internal/filesystem/operation_write_test.go new file mode 100644 index 000000000..aeb564c22 --- /dev/null +++ b/internal/filesystem/operation_write_test.go @@ -0,0 +1,311 @@ +package filesystem + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +type writeOperationFaultDispatcher struct { + inner *testCoordinator + key []byte + once bool + before func(context.Context) error + loseAck bool + loseRejection bool + rejectConflict bool +} + +type failWritePlanStore struct { + store.MVCCStore + key []byte + err error + once bool +} + +func (s *failWritePlanStore) GetAt(ctx context.Context, key []byte, ts uint64) ([]byte, error) { + if !s.once && bytes.Equal(key, s.key) { + s.once = true + return nil, s.err + } + return s.MVCCStore.GetAt(ctx, key, ts) +} + +func (d *writeOperationFaultDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if response, err, handled := d.handleRejection(ctx, req); handled { + return response, err + } + if !d.once && writeOperationTouchesKey(req, d.key) { + d.once = true + if d.before != nil { + if err := d.before(ctx); err != nil { + return nil, err + } + } + resp, err := d.inner.Dispatch(ctx, req) + if err == nil && d.loseAck { + return nil, kv.ErrTransactionOutcomeUnknown + } + return resp, err + } + return d.inner.Dispatch(ctx, req) +} + +func (d *writeOperationFaultDispatcher) handleRejection(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error, bool) { + if d.once || !isWriteOperationRejection(req) { + return nil, nil, false + } + if d.rejectConflict { + d.once = true + return nil, store.ErrWriteConflict, true + } + if d.loseRejection { + response, err := d.rejectWithLostReply(ctx, req) + return response, err, true + } + return nil, nil, false +} + +func (d *writeOperationFaultDispatcher) rejectWithLostReply(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + d.once = true + if _, err := d.inner.Dispatch(ctx, req); err != nil { + return nil, err + } + return nil, kv.ErrTransactionOutcomeUnknown +} + +func isWriteOperationRejection(req *kv.OperationGroup[kv.OP]) bool { + for _, elem := range req.Elems { + if elem == nil || !fskeys.IsOperationKey(elem.Key) { + continue + } + record, err := decodeOperationRecord(elem.Value) + return err == nil && record.State == OperationRejected + } + return false +} + +func writeOperationTouchesKey(req *kv.OperationGroup[kv.OP], key []byte) bool { + for _, elem := range req.Elems { + if elem != nil && string(elem.Key) == string(key) { + return true + } + } + return false +} + +func testWriteOperationID(t *testing.T, svc *Service, sequence uint64) OperationID { + t.Helper() + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "write-test") + require.NoError(t, err) + return OperationID{Filesystem: filesystemID, Session: session.Session, Sequence: sequence} +} + +func TestWriteWithOperationReturnsOriginalResultWithoutReplaying(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + n, err := svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("abcd")) + require.NoError(t, err) + require.Equal(t, 4, n) + _, err = svc.Write(ctx, file.Inode, 0, 0, []byte("NEW!")) + require.NoError(t, err) + n, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("abcd")) + require.NoError(t, err) + require.Equal(t, 4, n) + data, err := svc.Read(ctx, file.Inode, 0, 0, 4) + require.NoError(t, err) + require.Equal(t, []byte("NEW!"), data) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("different")) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestWriteWithOperationRetainsSemanticFailure(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + _, err := svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) + _, err = svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationRejected, record.State) +} + +func TestWriteWithOperationRetainsMissingInodeRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + _, err := svc.WriteWithOperation(ctx, id, 99, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) + _, err = svc.WriteWithOperation(ctx, id, 99, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestWriteOperationEffectLimitTracksChunkCount(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + one, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, 1) + require.NoError(t, err) + two, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, testChunkSize+1) + require.NoError(t, err) + require.Greater(t, two, one) + require.Less(t, two, uint32(writeOperationMaxEffectLimit)) +} + +func TestWriteWithOperationDoesNotReplayPendingAttempt(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + effectLimit, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, 1) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, encodeWriteOperationIntent(file.Inode, 0, []byte("x")), writeOperationResultBytes, effectLimit) + require.NoError(t, err) + _, err = journal.BeginAttempt(ctx, id) + require.NoError(t, err) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("x")) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + data, err := svc.Read(ctx, file.Inode, 0, 0, 1) + require.NoError(t, err) + require.Empty(t, data) +} + +func TestWriteWithOperationReplansAfterDefinitiveConflict(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + fault := &writeOperationFaultDispatcher{inner: inner, key: fskeys.ChunkKey(file.Inode, file.Inode, 0)} + fault.before = func(ctx context.Context) error { + _, err := svc.Write(ctx, file.Inode, 0, 1, []byte("Z")) + if err != nil { + return err + } + return store.ErrWriteConflict + } + svc.dispatch = fault + n, err := svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("a")) + require.NoError(t, err) + require.Equal(t, 1, n) + data, err := svc.Read(ctx, file.Inode, 0, 0, 2) + require.NoError(t, err) + require.Equal(t, []byte("aZ"), data) +} + +func TestWriteWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.ChunkKey(file.Inode, file.Inode, 0), loseAck: true} + n, err := svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("x")) + require.NoError(t, err) + require.Equal(t, 1, n) + data, err := svc.Read(ctx, file.Inode, 0, 0, 1) + require.NoError(t, err) + require.Equal(t, []byte("x"), data) +} + +func TestWriteWithOperationResolvesLostRejectionReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, loseRejection: true} + _, err := svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) + _, err = svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) +} + +func TestWriteWithOperationReplansDefinitiveRejectionConflict(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, rejectConflict: true} + _, err := svc.WriteWithOperation(ctx, id, 999, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) + _, err = svc.WriteWithOperation(ctx, id, 999, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestWritePlanningFailureCancelsAttemptWithStableResult(t *testing.T) { + for _, tc := range []struct { + name string + err error + want error + }{ + {name: "quota", err: ErrOperationQuota, want: ErrOperationQuota}, + {name: "other", err: errors.New("planner failed"), want: ErrWritePlanningFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + intent := encodeWriteOperationIntent(file.Inode, 0, []byte("x")) + limit, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, 1) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, intent, writeOperationResultBytes, limit) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + svc.store = &failWritePlanStore{MVCCStore: svc.store, key: fskeys.InodeKey(file.Inode), err: tc.err} + _, err = svc.executeWriteOperation(ctx, journal, id, attempt, file.Inode, 0, 1, []byte("x"), intent) + require.ErrorIs(t, err, tc.want) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("x")) + require.ErrorIs(t, err, tc.want) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationCancelled, record.State) + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) + data, err := svc.Read(ctx, file.Inode, 0, 0, 1) + require.NoError(t, err) + require.Empty(t, data) + }) + } +} diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index 9853094f9..d2ae05037 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -799,16 +799,30 @@ func (s *Service) writeOnce(ctx context.Context, inode uint64, offset uint64, da return 0, err } } + elems, readKeys, err := s.planWriteTxn(ctx, meta, ts, offset, end, data) + if err != nil { + return 0, err + } + if err := s.dispatchTxn(ctx, ts, elems, readKeys); err != nil { + return 0, s.translateHomeConflict(ctx, meta, err) + } + return len(data), nil +} + +// planWriteTxn reads every chunk and metadata predicate at ts. The caller +// must dispatch the returned effects with that same timestamp. +func (s *Service) planWriteTxn(ctx context.Context, meta *InodeMeta, ts, offset, end uint64, data []byte) ([]*kv.Elem[kv.OP], [][]byte, error) { + inode := meta.Inode chunkSize := meta.effectiveChunkSize(s.chunkSize) first := offset / chunkSize last := (end - 1) / chunkSize elemCap, err := checkedInt(last - first + uint64(directoryInitialNlink)) if err != nil { - return 0, err + return nil, nil, err } chunkLen, err := checkedInt(chunkSize) if err != nil { - return 0, err + return nil, nil, err } elems := make([]*kv.Elem[kv.OP], 0, elemCap) readKeys := [][]byte{fskeys.InodeKey(inode), fskeys.HomeKey(inode)} @@ -821,18 +835,18 @@ func (s *Service) writeOnce(ctx context.Context, inode uint64, offset uint64, da writeEnd := min(end, chunkEnd) srcStart, err := checkedInt(writeStart - offset) if err != nil { - return 0, err + return nil, nil, err } srcEnd, err := checkedInt(writeEnd - offset) if err != nil { - return 0, err + return nil, nil, err } fullChunkWrite := writeStart == chunkStart && writeEnd == chunkEnd oldLen, value, readChunk, err := s.writeChunkValue( ctx, ts, chunkKey, chunkLen, fullChunkWrite, data, srcStart, srcEnd, writeStart, chunkStart, ) if err != nil { - return 0, err + return nil, nil, err } if readChunk { readKeys = append(readKeys, chunkKey) @@ -844,25 +858,25 @@ func (s *Service) writeOnce(ctx context.Context, inode uint64, offset uint64, da elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: chunkKey, Value: value}) } } - now := s.now().UnixNano() + return s.finishWritePlan(ctx, meta, ts, end, elems, readKeys, delta) +} + +func (s *Service) finishWritePlan(ctx context.Context, meta *InodeMeta, ts, end uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte, delta usageDelta) ([]*kv.Elem[kv.OP], [][]byte, error) { if end > meta.Size { meta.Size = end } - meta.MtimeNsec = now - meta.CtimeNsec = now - elem, err := putElem(fskeys.InodeKey(inode), meta) + meta.MtimeNsec = s.now().UnixNano() + meta.CtimeNsec = meta.MtimeNsec + elem, err := putElem(fskeys.InodeKey(meta.Inode), meta) if err != nil { - return 0, err + return nil, nil, err } elems = append(elems, elem) elems, readKeys, err = s.appendUsageUpdate(ctx, ts, elems, readKeys, delta) if err != nil { - return 0, err - } - if err := s.dispatchTxn(ctx, ts, elems, readKeys); err != nil { - return 0, s.translateHomeConflict(ctx, meta, err) + return nil, nil, err } - return len(data), nil + return elems, readKeys, nil } func (s *Service) writeChunkValue( @@ -1623,6 +1637,30 @@ func (s *Service) RefreshOpenHandleLease(ctx context.Context, inode uint64, fh u return ErrContentionLimit } +// OpenHandleLeaseTTL exposes the configured renewal budget to FUSE. +func (s *Service) OpenHandleLeaseTTL() time.Duration { + return s.openLeaseTTL +} + +// ValidateOpenHandleLease checks a handle without extending its lease. +func (s *Service) ValidateOpenHandleLease(ctx context.Context, inode uint64, fh uint64, clientID []byte) error { + ts, err := s.readTS(ctx) + if err != nil { + return err + } + if _, err := s.inodeAt(ctx, inode, ts); err != nil { + return err + } + lease, exists, err := s.openHandleLeaseAt(ctx, fskeys.RefKey(inode, clientID, fh), ts) + if err != nil { + return err + } + if !exists || lease.expired(s.now().UnixNano()) { + return errors.WithStack(ErrNotFound) + } + return nil +} + func (s *Service) refreshOpenHandleLeaseOnce(ctx context.Context, inode uint64, fh uint64, clientID []byte) error { ts, err := s.readTS(ctx) if err != nil { @@ -2802,11 +2840,36 @@ func (s *Service) dispatchTxnWithPolicy( elems []*kv.Elem[kv.OP], readKeys [][]byte, requireAllParticipants bool, +) (*kv.CoordinateResponse, error) { + return s.dispatchTxnWithPrimaryPolicy(ctx, startTS, elems, readKeys, requireAllParticipants, nil) +} + +func (s *Service) dispatchTxnWithPrimaryPolicy( + ctx context.Context, + startTS uint64, + elems []*kv.Elem[kv.OP], + readKeys [][]byte, + requireAllParticipants bool, + primary []byte, +) (*kv.CoordinateResponse, error) { + return s.dispatchTxnWithPrimaryAtRoute(ctx, startTS, elems, readKeys, requireAllParticipants, primary, 0) +} + +func (s *Service) dispatchTxnWithPrimaryAtRoute( + ctx context.Context, + startTS uint64, + elems []*kv.Elem[kv.OP], + readKeys [][]byte, + requireAllParticipants bool, + primary []byte, + observedRouteVersion uint64, ) (*kv.CoordinateResponse, error) { startTS = normalizeEmptyStoreReadTS(startTS) reqs := &kv.OperationGroup[kv.OP]{ Elems: elems, IsTxn: true, + PrimaryKey: primary, + ObservedRouteVersion: observedRouteVersion, RequireAllParticipants: requireAllParticipants, StartTS: startTS, ReadKeys: readKeys, diff --git a/internal/fskeys/keys.go b/internal/fskeys/keys.go index aaf8a6883..738dc013c 100644 --- a/internal/fskeys/keys.go +++ b/internal/fskeys/keys.go @@ -73,6 +73,13 @@ func OperationKey(filesystemID, sessionID [16]byte, sequence uint64) []byte { return appendU64(out, sequence) } +// IsOperationKey identifies an exact operation row, excluding other journal +// metadata families and malformed prefixes. +func IsOperationKey(key []byte) bool { + return bytes.HasPrefix(key, []byte(operationPrefix)) && + len(key) == len(operationPrefix)+operationIdentityBytes+u64Bytes +} + // OperationSessionKey names one durable mount incarnation. func OperationSessionKey(filesystemID, sessionID [16]byte) []byte { out := make([]byte, 0, len(operationSessionPrefix)+operationIdentityBytes) diff --git a/kv/coordinator.go b/kv/coordinator.go index 1fa2d5cca..d72f5694a 100644 --- a/kv/coordinator.go +++ b/kv/coordinator.go @@ -796,7 +796,7 @@ func (c *Coordinate) dispatchOnce(ctx context.Context, reqs *OperationGroup[OP]) var resp *CoordinateResponse var err error if reqs.IsTxn { - resp, err = c.dispatchTxn(ctx, reqs.Elems, reqs.ReadKeys, reqs.StartTS, reqs.CommitTS, reqs.PrevCommitTS, reqs.ObservedRouteVersion, reqs.KeyVizLabel) + resp, err = c.dispatchTxn(ctx, reqs.Elems, reqs.ReadKeys, reqs.StartTS, reqs.CommitTS, reqs.PrevCommitTS, reqs.ObservedRouteVersion, reqs.KeyVizLabel, reqs.PrimaryKey) } else { resp, err = c.dispatchRaw(ctx, reqs.Elems, reqs.KeyVizLabel) } @@ -1315,11 +1315,14 @@ func (c *Coordinate) resolveDispatchCommitTS(ctx context.Context, commitTS, star return retry, nil } -func (c *Coordinate) dispatchTxn(ctx context.Context, reqs []*Elem[OP], readKeys [][]byte, startTS uint64, commitTS uint64, prevCommitTS uint64, observedRouteVersion uint64, label keyviz.Label) (*CoordinateResponse, error) { +func (c *Coordinate) dispatchTxn(ctx context.Context, reqs []*Elem[OP], readKeys [][]byte, startTS uint64, commitTS uint64, prevCommitTS uint64, observedRouteVersion uint64, label keyviz.Label, selectedPrimary ...[]byte) (*CoordinateResponse, error) { if len(readKeys) > maxReadKeys { return nil, errors.WithStack(ErrInvalidRequest) } primary := primaryKeyForElems(reqs) + if len(selectedPrimary) != 0 && len(selectedPrimary[0]) != 0 { + primary = selectedPrimary[0] + } if len(primary) == 0 { return nil, errors.WithStack(ErrTxnPrimaryKeyRequired) } @@ -1592,6 +1595,9 @@ func (c *Coordinate) buildRedirectRequests(reqs *OperationGroup[OP]) ([]*pb.Requ return nil, errors.WithStack(ErrInvalidRequest) } primary := primaryKeyForElems(reqs.Elems) + if len(reqs.PrimaryKey) != 0 { + primary = reqs.PrimaryKey + } if len(primary) == 0 { return nil, errors.WithStack(ErrTxnPrimaryKeyRequired) } @@ -1725,3 +1731,10 @@ func primaryKeyForElems(reqs []*Elem[OP]) []byte { } return primary } + +func transactionPrimaryKey(reqs []*Elem[OP], selected []byte) []byte { + if len(selected) != 0 { + return selected + } + return primaryKeyForElems(reqs) +} diff --git a/kv/coordinator_forwarder.go b/kv/coordinator_forwarder.go index 6d2e67e17..6555dd848 100644 --- a/kv/coordinator_forwarder.go +++ b/kv/coordinator_forwarder.go @@ -270,6 +270,16 @@ func (f CoordinatorForwarder) ResolveOperationGroupForKey(key []byte) (uint64, e return group, errors.WithStack(err) } +// JournalRouteVersion carries the route pin through coordinator wrappers. +// An unavailable pin must remain unavailable to the operation journal. +func (f CoordinatorForwarder) JournalRouteVersion() (uint64, bool) { + source, ok := f.Inner.(interface{ JournalRouteVersion() (uint64, bool) }) + if !ok { + return 0, false + } + return source.JournalRouteVersion() +} + // LocalLeaderGroupIDs lists the data groups this process leads. The Redis // delta compactor's TTL-inline migration plans one scan per group from it. // An Inner without the capability stays unknown rather than leading no group, diff --git a/kv/coordinator_txn_test.go b/kv/coordinator_txn_test.go index 86db08090..1aa42bd3a 100644 --- a/kv/coordinator_txn_test.go +++ b/kv/coordinator_txn_test.go @@ -97,6 +97,34 @@ func TestCoordinateDispatchTxn_UsesOnePhaseRequest(t *testing.T) { require.Greater(t, meta.CommitTS, startTS) } +func TestCoordinateDispatchTxn_UsesSelectedPrimary(t *testing.T) { + t.Parallel() + tx := &stubTransactional{} + c := &Coordinate{transactionManager: tx, clock: NewHLC()} + _, err := c.dispatchTxn(context.Background(), []*Elem[OP]{ + {Op: Put, Key: []byte("a")}, + {Op: Put, Key: []byte("z")}, + }, nil, 10, 0, 0, 0, keyviz.LabelLegacy, []byte("z")) + require.NoError(t, err) + meta, err := DecodeTxnMeta(tx.reqs[0][0].Mutations[0].Value) + require.NoError(t, err) + require.Equal(t, []byte("z"), meta.PrimaryKey) +} + +func TestCoordinateForwardTxnPreservesSelectedPrimary(t *testing.T) { + t.Parallel() + c := &Coordinate{} + requests, err := c.buildRedirectRequests(&OperationGroup[OP]{ + IsTxn: true, StartTS: 10, PrimaryKey: []byte("z"), + Elems: []*Elem[OP]{{Op: Put, Key: []byte("a")}, {Op: Put, Key: []byte("z")}}, + }) + require.NoError(t, err) + require.Len(t, requests, 1) + meta, err := DecodeTxnMeta(requests[0].Mutations[0].Value) + require.NoError(t, err) + require.Equal(t, []byte("z"), meta.PrimaryKey) +} + func TestCoordinateDispatchTxn_UsesProvidedCommitTS(t *testing.T) { t.Parallel() diff --git a/kv/sharded_coordinator.go b/kv/sharded_coordinator.go index 2369f2383..37a5ab2ea 100644 --- a/kv/sharded_coordinator.go +++ b/kv/sharded_coordinator.go @@ -1202,6 +1202,7 @@ func (c *ShardedCoordinator) dispatchTxnWithComposed1Retry(ctx context.Context, reqs.KeyVizLabel, callerSuppliedStartTS, reqs.RequireAllParticipants, + reqs.PrimaryKey, ) if err == nil { return resp, nil @@ -1319,6 +1320,16 @@ func (c *ShardedCoordinator) pinStrictRouteVersion(reqs *OperationGroup[OP]) { } } +// JournalRouteVersion returns a pin for journal read/modify/write operations. +// Version zero is not a usable pin until its wire encoding is enabled. +func (c *ShardedCoordinator) JournalRouteVersion() (uint64, bool) { + if c == nil || c.engine == nil { + return 0, false + } + observed := EncodeObservedRouteVersion(c.engine.Version()) + return observed, observed != 0 +} + func (c *ShardedCoordinator) ensureStrictRouteVersion(observed uint64, strict bool) error { if !strict || c.engine == nil { return nil @@ -1760,17 +1771,18 @@ func (c *ShardedCoordinator) dispatchTxn( observedRouteVersion uint64, label keyviz.Label, callerSuppliedStartTS bool, - requireAllParticipants ...bool, + requireAllParticipants bool, + selectedPrimary []byte, ) (*CoordinateResponse, error) { if len(readKeys) > maxReadKeys { return nil, errors.WithStack(ErrInvalidRequest) } - grouped, gids, err := c.groupTxnMutations(elems, label, observedRouteVersion, requireAllParticipantsFlag(requireAllParticipants)) + grouped, gids, err := c.groupTxnMutations(elems, label, observedRouteVersion, requireAllParticipants) if err != nil { return nil, err } bypassKeysByGroup := c.writeFenceBypassKeysByGroup(elems) - primaryKey := primaryKeyForElems(elems) + primaryKey := transactionPrimaryKey(elems, selectedPrimary) if len(primaryKey) == 0 { return nil, errors.WithStack(ErrTxnPrimaryKeyRequired) } @@ -1798,7 +1810,7 @@ func (c *ShardedCoordinator) dispatchTxn( if err := StampGroupedMutationCommitTS(grouped, commitTS); err != nil { return nil, err } - return c.dispatchMultiShardTxn(ctx, startTS, commitTS, prevCommitTS, primaryKey, grouped, gids, readKeys, observedRouteVersion, bypassKeysByGroup, requireAllParticipantsFlag(requireAllParticipants)) + return c.dispatchMultiShardTxn(ctx, startTS, commitTS, prevCommitTS, primaryKey, grouped, gids, readKeys, observedRouteVersion, bypassKeysByGroup, requireAllParticipants) } func (c *ShardedCoordinator) groupTxnMutations(elems []*Elem[OP], label keyviz.Label, observedRouteVersion uint64, strict bool) (map[uint64][]*pb.Mutation, []uint64, error) { @@ -3287,6 +3299,21 @@ func validateOperationGroup(reqs *OperationGroup[OP]) error { return ErrInvalidRequest } } + if len(reqs.PrimaryKey) != 0 { + if !reqs.IsTxn { + return ErrInvalidRequest + } + found := false + for _, e := range reqs.Elems { + if bytes.Equal(e.Key, reqs.PrimaryKey) { + found = true + break + } + } + if !found { + return ErrInvalidRequest + } + } return nil } @@ -3385,6 +3412,11 @@ func (c *ShardedCoordinator) stampRawRequestTimestamps(ctx context.Context, reqs func (c *ShardedCoordinator) txnLogs(ctx context.Context, reqs *OperationGroup[OP]) ([]*pb.Request, error) { // NOTE: ShardedCoordinator implements distributed transactions directly in // Dispatch. txnLogs is retained for compatibility and single-shard helpers. + if len(reqs.PrimaryKey) != 0 { + // This legacy builder selects a primary independently per group. + // Never silently discard an explicit transaction primary. + return nil, errors.WithStack(ErrInvalidRequest) + } grouped, gids, err := c.groupMutations(reqs.Elems, reqs.KeyVizLabel) if err != nil { return nil, err diff --git a/kv/sharded_coordinator_txn_test.go b/kv/sharded_coordinator_txn_test.go index 8d674a7d0..8b085ed7a 100644 --- a/kv/sharded_coordinator_txn_test.go +++ b/kv/sharded_coordinator_txn_test.go @@ -621,6 +621,56 @@ func TestShardedCoordinatorDispatchTxn_CrossShardPhasesAndCommitIndex(t *testing require.Zero(t, binary.BigEndian.Uint64(value2[4:12])) } +func TestShardedCoordinatorDispatchTxn_SelectedPrimaryCommitsFirst(t *testing.T) { + t.Parallel() + engine := distribution.NewEngine() + applyComposed1Snapshot(t, engine, 1, []distribution.RouteDescriptor{ + {RouteID: 1, Start: []byte("a"), End: []byte("m"), GroupID: 1}, + {RouteID: 2, Start: []byte("m"), GroupID: 2}, + }) + g1Txn := &recordingTransactional{responses: []*TransactionResponse{{CommitIndex: 1}, {CommitIndex: 2}}} + g2Txn := &recordingTransactional{responses: []*TransactionResponse{{CommitIndex: 3}, {CommitIndex: 4}}} + var orderMu sync.Mutex + var commitOrder []string + for group, txn := range map[string]*recordingTransactional{"a": g1Txn, "z": g2Txn} { + txn.onCommit = func(_ int, request *pb.Request) { + if request.Phase == pb.Phase_COMMIT { + orderMu.Lock() + commitOrder = append(commitOrder, group) + orderMu.Unlock() + } + } + } + coord := NewShardedCoordinator(engine, map[uint64]*ShardGroup{ + 1: {Txn: g1Txn}, + 2: {Txn: g2Txn}, + }, 1, NewHLC(), nil) + _, err := coord.Dispatch(context.Background(), &OperationGroup[OP]{ + IsTxn: true, StartTS: 10, PrimaryKey: []byte("z"), RequireAllParticipants: true, + ObservedRouteVersion: EncodeObservedRouteVersion(engine.Version()), + Elems: []*Elem[OP]{{Op: Put, Key: []byte("a")}, {Op: Put, Key: []byte("z")}}, + }) + require.NoError(t, err) + require.Equal(t, []string{"z", "a"}, commitOrder) + require.Equal(t, pb.Phase_COMMIT, g2Txn.requests[1].Phase) + require.Equal(t, pb.Phase_COMMIT, g1Txn.requests[1].Phase) + for _, requests := range [][]*pb.Request{g1Txn.requests, g2Txn.requests} { + for _, request := range requests { + require.Equal(t, []byte("z"), requestTxnMeta(t, request).PrimaryKey) + } + } +} + +func TestShardedCoordinatorRejectsUnwrittenSelectedPrimary(t *testing.T) { + t.Parallel() + coord := NewShardedCoordinator(nil, nil, 1, NewHLC(), nil) + _, err := coord.Dispatch(context.Background(), &OperationGroup[OP]{ + IsTxn: true, PrimaryKey: []byte("missing"), + Elems: []*Elem[OP]{{Op: Put, Key: []byte("a")}}, + }) + require.ErrorIs(t, err, ErrInvalidRequest) +} + func TestShardedCoordinatorDispatchTxn_PhaseDRejectsInvalidCallerStartTSBeforeProposal(t *testing.T) { t.Parallel() coord, g1Txn, g2Txn, alloc := newPhaseDCrossShardCoordinator(t, ErrTSOTimestampInvalid) diff --git a/kv/sharded_integration_test.go b/kv/sharded_integration_test.go index 91ed87ee6..c27692551 100644 --- a/kv/sharded_integration_test.go +++ b/kv/sharded_integration_test.go @@ -188,7 +188,7 @@ func TestShardedCoordinatorStrictCrossShardTxnConfirmsEveryEffect(t *testing.T) shardStore := NewShardStore(router, groups) coord := NewShardedCoordinator(router, groups, 1, NewHLC(), shardStore) resp, err := coord.Dispatch(ctx, &OperationGroup[OP]{ - IsTxn: true, RequireAllParticipants: true, + IsTxn: true, RequireAllParticipants: true, PrimaryKey: []byte("x"), Elems: []*Elem[OP]{ {Op: Put, Key: []byte("b"), Value: []byte("record")}, {Op: Put, Key: []byte("x"), Value: []byte("effect")}, diff --git a/kv/transcoder.go b/kv/transcoder.go index e94d0c43c..df58f1925 100644 --- a/kv/transcoder.go +++ b/kv/transcoder.go @@ -74,6 +74,10 @@ type Elem[T OP] struct { type OperationGroup[T OP] struct { Elems []*Elem[T] IsTxn bool + // PrimaryKey optionally selects a written key as the transaction's + // primary. Empty preserves the lexicographically smallest-key default. + // Callers that use it must keep the same key across every attempt. + PrimaryKey []byte // RequireAllParticipants makes a committed primary insufficient for a // successful response: every secondary must acknowledge apply. Used by // operation-result publication, whose success includes visible effects. diff --git a/main.go b/main.go index b48493495..16fee45a7 100644 --- a/main.go +++ b/main.go @@ -123,6 +123,8 @@ var ( pprofToken = flag.String("pprofToken", "", "Bearer token for pprof; required for non-loopback pprofAddress") filesystemMount = flag.String("filesystemMount", "", "FUSE mount point for the Elastickv filesystem; empty to disable") filesystemClientID = flag.String("filesystemClientID", "", "Stable open-handle lease client ID for the FUSE mount; empty uses raftId") + filesystemStrict = flag.Bool("filesystemStrict", false, "Use the operation journal for supported FUSE mutations") + filesystemStrictRecover = flag.Bool("filesystemStrictRecover", false, "Fence and recover a previous strict FUSE session for the same client ID; use only after the previous mount has stopped") filesystemCapacity = flag.Uint64("filesystemCapacity", 0, "Filesystem byte capacity reported by statfs; zero reports unlimited") filesystemMaxFiles = flag.Uint64("filesystemMaxFiles", 0, "Filesystem inode capacity reported by statfs; zero reports unlimited") filesystemRootMode = flag.Uint("filesystemRootMode", defaultFilesystemRootMode, "Root directory permission mode for first initialization") @@ -994,11 +996,13 @@ func startRaftEngineLifecycleWatchers(ctx context.Context, eg *errgroup.Group, r } type filesystemStartConfig struct { - mountPoint string - clientID string - rootMode uint32 - rootUID uint32 - rootGID uint32 + mountPoint string + clientID string + strict bool + strictRecover bool + rootMode uint32 + rootUID uint32 + rootGID uint32 // mountOptions overrides go-fuse's mount options. Production leaves it // nil (fusermount3 path); tests set DirectMount where no helper exists. mountOptions *fuse.MountOptions @@ -1013,6 +1017,11 @@ func filesystemStartConfigFromFlags() (filesystemStartConfig, error) { return filesystemStartConfig{}, errors.New("filesystem root mode, uid, or gid is out of range") } config.clientID = strings.TrimSpace(*filesystemClientID) + config.strict = *filesystemStrict + config.strictRecover = *filesystemStrictRecover + if config.strictRecover && !config.strict { + return filesystemStartConfig{}, errors.New("filesystemStrictRecover requires filesystemStrict") + } if config.clientID == "" { config.clientID = strings.TrimSpace(*raftId) } @@ -1081,7 +1090,11 @@ func mountFilesystemService( service *filesystem.Service, config filesystemStartConfig, ) (*fuseadapter.Server, <-chan struct{}, error) { - frontend := fuseadapter.New(service, []byte(config.clientID)) + adapterOptions, err := strictFilesystemOptions(ctx, service, config) + if err != nil { + return nil, nil, err + } + frontend := fuseadapter.New(service, []byte(config.clientID), adapterOptions...) server, err := fuseadapter.MountContext(ctx, config.mountPoint, frontend, config.mountOptions) if err != nil { frontend.Close() @@ -1100,6 +1113,30 @@ func mountFilesystemService( return server, serveDone, nil } +func strictFilesystemOptions(ctx context.Context, service *filesystem.Service, config filesystemStartConfig) ([]fuseadapter.Option, error) { + if !config.strict { + return nil, nil + } + journal, err := filesystem.NewOperationJournal(service) + if err != nil { + return nil, errors.Wrap(err, "create strict filesystem journal") + } + filesystemID, err := journal.EnsureFilesystem(ctx) + if err != nil { + return nil, errors.Wrap(err, "identify strict filesystem") + } + if config.strictRecover { + if err := journal.RecoverOwnerSession(ctx, filesystemID, config.clientID); err != nil { + return nil, errors.Wrap(err, "recover previous strict filesystem session") + } + } + session, err := journal.RegisterSession(ctx, filesystemID, config.clientID) + if err != nil { + return nil, errors.Wrap(err, "register strict filesystem session") + } + return []fuseadapter.Option{fuseadapter.WithStrictMode(session)}, nil +} + // filesystemMountServer is the part of *fuseadapter.Server the shutdown // lifecycle drives; tests substitute a fake to exercise the failure paths. type filesystemMountServer interface { diff --git a/main_coordinator_capabilities_test.go b/main_coordinator_capabilities_test.go index 70b89c5ed..2b3a0264e 100644 --- a/main_coordinator_capabilities_test.go +++ b/main_coordinator_capabilities_test.go @@ -145,6 +145,11 @@ func (s *shardedCapabilitySpy) ResolveOperationGroupForKey([]byte) (uint64, erro return spyIndex, errSpyForwarded } +func (s *shardedCapabilitySpy) JournalRouteVersion() (uint64, bool) { + s.record("JournalRouteVersion") + return spyIndex, true +} + func (s *shardedCapabilitySpy) LocalLeaderGroupIDs() ([]uint64, bool) { s.record("LocalLeaderGroupIDs") return []uint64{spyIndex}, true diff --git a/main_filesystem_test.go b/main_filesystem_test.go index 89bf536eb..fa6845311 100644 --- a/main_filesystem_test.go +++ b/main_filesystem_test.go @@ -51,22 +51,28 @@ func TestRunFilesystemLeaseReaperRunsPeriodically(t *testing.T) { func TestFilesystemStartConfigFromFlags(t *testing.T) { oldMount, oldClientID, oldRaftID := *filesystemMount, *filesystemClientID, *raftId + oldStrict := *filesystemStrict + oldStrictRecover := *filesystemStrictRecover oldMode, oldUID, oldGID := *filesystemRootMode, *filesystemRootUID, *filesystemRootGID t.Cleanup(func() { *filesystemMount, *filesystemClientID, *raftId = oldMount, oldClientID, oldRaftID + *filesystemStrict = oldStrict + *filesystemStrictRecover = oldStrictRecover *filesystemRootMode, *filesystemRootUID, *filesystemRootGID = oldMode, oldUID, oldGID }) tests := []struct { - name string - mount string - clientID string - raftID string - mode uint - uid uint - gid uint - want filesystemStartConfig - wantErr string + name string + mount string + clientID string + raftID string + mode uint + uid uint + gid uint + want filesystemStartConfig + wantErr string + strict bool + strictRecover bool }{ { name: "disabled when mount point is blank", @@ -91,6 +97,22 @@ func TestFilesystemStartConfigFromFlags(t *testing.T) { mode: 0o755, want: filesystemStartConfig{mountPoint: "/mnt/fs", clientID: "mount-a", rootMode: 0o755}, }, + { + name: "strict mount is explicit", + mount: "/mnt/fs", + raftID: "n1", + mode: 0o755, + strict: true, + want: filesystemStartConfig{mountPoint: "/mnt/fs", clientID: "n1", strict: true, rootMode: 0o755}, + }, + { + name: "strict recovery needs strict mount", + mount: "/mnt/fs", + raftID: "n1", + mode: 0o755, + strictRecover: true, + wantErr: "requires filesystemStrict", + }, { name: "client id required", mount: "/mnt/fs", @@ -125,6 +147,8 @@ func TestFilesystemStartConfigFromFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { *filesystemMount, *filesystemClientID, *raftId = tt.mount, tt.clientID, tt.raftID + *filesystemStrict = tt.strict + *filesystemStrictRecover = tt.strictRecover *filesystemRootMode, *filesystemRootUID, *filesystemRootGID = tt.mode, tt.uid, tt.gid got, err := filesystemStartConfigFromFlags() diff --git a/scripts/test-fuse-sqlite.py b/scripts/test-fuse-sqlite.py index 17ddefab2..96e565091 100644 --- a/scripts/test-fuse-sqlite.py +++ b/scripts/test-fuse-sqlite.py @@ -9,22 +9,33 @@ import uuid +BUSY_TIMEOUT_SECONDS = 90 +WRITER_JOIN_SECONDS = 300 + + def increment(path, mode, writes, start, results): + phase = "open" try: - if not start.wait(10): - raise TimeoutError("writer start timed out") - with closing(sqlite3.connect(path, timeout=10)) as connection: + with closing(sqlite3.connect(path, timeout=BUSY_TIMEOUT_SECONDS)) as connection: + # PERSIST is connection-local, so each writer must select it. + # Configure the connections before either starts writing. + phase = "configure journal mode" actual_mode = connection.execute(f"PRAGMA journal_mode={mode}").fetchone()[0] if actual_mode.upper() != mode: raise AssertionError(f"journal mode {actual_mode}, expected {mode}") connection.execute("PRAGMA synchronous=FULL") - for _ in range(writes): + results.put("ready") + phase = "wait for writers" + if not start.wait(BUSY_TIMEOUT_SECONDS): + raise TimeoutError("writer start timed out") + for index in range(writes): + phase = f"write {index + 1}/{writes}" connection.execute("BEGIN IMMEDIATE") connection.execute("UPDATE counter SET value=value+1") connection.commit() results.put(None) except Exception as exc: # The parent reports the exact worker failure. - results.put(repr(exc)) + results.put(f"{phase}: {exc!r}") def run_mode(mount, mode, writers, writes): @@ -46,9 +57,12 @@ def run_mode(mount, mode, writers, writes): ] for process in processes: process.start() + ready = results.get(timeout=BUSY_TIMEOUT_SECONDS) + if ready != "ready": + raise RuntimeError(f"{mode}: writer setup failed: {ready}") start.set() for process in processes: - process.join(120) + process.join(WRITER_JOIN_SECONDS) if process.is_alive(): process.terminate() process.join() diff --git a/third_party/go-fuse/LICENSE b/third_party/go-fuse/LICENSE new file mode 100644 index 000000000..5c1d980e3 --- /dev/null +++ b/third_party/go-fuse/LICENSE @@ -0,0 +1,30 @@ +New BSD License + +Copyright (c) 2010 the Go-FUSE Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Ivan Krasin nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/third_party/go-fuse/README.md b/third_party/go-fuse/README.md new file mode 100644 index 000000000..9e671e31a --- /dev/null +++ b/third_party/go-fuse/README.md @@ -0,0 +1,13 @@ +# Local go-fuse bridge + +This directory contains the `fuse` and `splice` packages from +`github.com/hanwen/go-fuse/v2` v2.12.0, under its BSD license. + +The local change adds `ReplyAttemptObserver` immediately after +`Server.handleRequest` attempts to write a response to the FUSE device. +`ReplySuppressedObserver` runs instead when go-fuse has decided to suppress +the reply. Elastickv uses either terminal boundary to retire filesystem +operation IDs after resolving their journal outcome. + +When updating go-fuse, preserve both notification boundaries, then rerun the +kernel FUSE tests. diff --git a/third_party/go-fuse/fuse/.gitignore b/third_party/go-fuse/fuse/.gitignore new file mode 100644 index 000000000..13df91618 --- /dev/null +++ b/third_party/go-fuse/fuse/.gitignore @@ -0,0 +1 @@ +version.gen.go diff --git a/third_party/go-fuse/fuse/api.go b/third_party/go-fuse/fuse/api.go new file mode 100644 index 000000000..607a6048f --- /dev/null +++ b/third_party/go-fuse/fuse/api.go @@ -0,0 +1,528 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package fuse provides APIs to implement filesystems in +// userspace in terms of raw FUSE protocol. +// +// A filesystem is implemented by implementing its server that provides a +// RawFileSystem interface. Typically the server embeds +// NewDefaultRawFileSystem() and implements only subset of filesystem methods: +// +// type MyFS struct { +// fuse.RawFileSystem +// ... +// } +// +// func NewMyFS() *MyFS { +// return &MyFS{ +// RawFileSystem: fuse.NewDefaultRawFileSystem(), +// ... +// } +// } +// +// // Mkdir implements "mkdir" request handler. +// // +// // For other requests - not explicitly implemented by MyFS - ENOSYS +// // will be typically returned to client. +// func (fs *MyFS) Mkdir(...) { +// ... +// } +// +// Then the filesystem can be mounted and served to a client (typically OS +// kernel) by creating Server: +// +// fs := NewMyFS() // implements RawFileSystem +// fssrv, err := fuse.NewServer(fs, mountpoint, &fuse.MountOptions{...}) +// if err != nil { +// ... +// } +// +// and letting the server do its work: +// +// // either synchronously - .Serve() blocks until the filesystem is unmounted. +// fssrv.Serve() +// +// // or in the background - .Serve() is spawned in another goroutine, but +// // before interacting with fssrv from current context we have to wait +// // until the filesystem mounting is complete. +// go fssrv.Serve() +// err = fssrv.WaitMount() +// if err != nil { +// ... +// } +// +// The server will serve clients by dispatching their requests to the +// filesystem implementation and conveying responses back. For example "mkdir" +// FUSE request dispatches to call +// +// fs.Mkdir(*MkdirIn, ..., *EntryOut) +// +// "stat" to call +// +// fs.GetAttr(*GetAttrIn, *AttrOut) +// +// etc. Please refer to RawFileSystem documentation for details. +// +// Typically, each call of the API happens in its own +// goroutine, so take care to make the file system thread-safe. +// +// Be careful when you access the FUSE mount from the same process. An access can +// tie up two OS threads (one on the request side and one on the FUSE server side). +// This can deadlock if there is no free thread to handle the FUSE server side. +// Run your program with GOMAXPROCS=1 to make the problem easier to reproduce, +// see https://github.com/hanwen/go-fuse/issues/261 for an example of that +// problem. +// +// # Higher level interfaces +// +// As said above this packages provides way to implement filesystems in terms of +// raw FUSE protocol. +// +// Package github.com/hanwen/go-fuse/v2/fs provides way to implement +// filesystems in terms of paths and/or inodes. +// +// # Mount styles +// +// The NewServer() handles mounting the filesystem, which +// involves opening `/dev/fuse` and calling the +// `mount(2)` syscall. The latter needs root permissions. +// This is handled in one of three ways: +// +// 1) go-fuse opens `/dev/fuse` and executes the `fusermount` +// setuid-root helper to call `mount(2)` for us. This is the default. +// Does not need root permissions but needs `fusermount` installed. +// +// 2) If `MountOptions.DirectMount` is set, go-fuse calls `mount(2)` itself. +// Needs root permissions, but works without `fusermount`. +// +// 3) If `mountPoint` has the magic `/dev/fd/N` syntax, it means that that a +// privileged parent process: +// +// * Opened /dev/fuse +// +// * Called mount(2) on a real mountpoint directory that we don't know about +// +// * Inherited the fd to /dev/fuse to us +// +// * Informs us about the fd number via /dev/fd/N +// +// This magic syntax originates from libfuse [1] and allows the FUSE server to +// run without any privileges and without needing `fusermount`, as the parent +// process performs all privileged operations. +// +// The "privileged parent" is usually a container manager like Singularity [2], +// but for testing, it can also be the `mount.fuse3` helper with the +// `drop_privileges,setuid=$USER` flags. Example below for gocryptfs: +// +// $ sudo mount.fuse3 "/usr/local/bin/gocryptfs#/tmp/cipher" /tmp/mnt -o drop_privileges,setuid=$USER +// +// [1] https://github.com/libfuse/libfuse/commit/64e11073b9347fcf9c6d1eea143763ba9e946f70 +// +// [2] https://sylabs.io/guides/3.7/user-guide/bind_paths_and_mounts.html#fuse-mounts +// +// # Aborting a file system +// +// A caller that has an open file in a buggy or crashed FUSE +// filesystem will be hung. The easiest way to clean up this situation +// is through the fusectl filesystem. By writing into +// /sys/fs/fuse/connection/$ID/abort, reads from the FUSE device fail, +// and all callers receive ENOTCONN (transport endpoint not connected) +// on their pending syscalls. The FUSE connection ID can be found as +// the Dev field in the Stat_t result for a file in the mount. +package fuse + +import "log" + +// Types for users to implement. + +// The result of Read is an array of bytes, but for performance +// reasons, we can also return data as a file-descriptor/offset/size +// tuple. If the backing store for a file is another filesystem, this +// reduces the amount of copying between the kernel and the FUSE +// server. The ReadResult interface captures both cases. +type ReadResult interface { + // Returns the raw bytes for the read, possibly using the + // passed buffer. The buffer should be larger than the return + // value from Size. + Bytes(buf []byte) ([]byte, Status) + + // Size returns how many bytes this return value takes at most. + Size() int + + // Done() is called after sending the data to the kernel. + Done() +} + +type MountOptions struct { + AllowOther bool + + // Options are the options passed as -o string to fusermount. + Options []string + + // MaxBackground controls the maximum number of allowed backgruond + // asynchronous I/O requests. + // + // If unset, the default is _DEFAULT_BACKGROUND_TASKS, 12. + // Concurrency for synchronous I/O is not limited. + MaxBackground int + + // MaxInflightRequestBytes controls the number of bytes used for + // request structs and input buffers checked out by go-fuse. This + // includes buffers used by readers waiting on the kernel and requests + // being processed concurrently. + // + // It also applies to requests that do not expect a reply, such as + // FORGET and BATCH_FORGET. If unset, it defaults to math.MaxInt. If + // set smaller than the bytes needed for a single request, one request + // is still allowed through. + MaxInflightRequestBytes int + + // CongestionThreshold is the in-flight async-request count at which + // the kernel marks the FUSE backing-dev as congested, throttling new + // submissions. It corresponds to + // /sys/fs/fuse/connections//congestion_threshold. + // + // If 0, go-fuse falls back to the kernel-FUSE convention of + // 3/4 * MaxBackground. The value is silently clamped by the kernel + // to MaxBackground if it is set higher. + CongestionThreshold int + + // MaxWrite is the max size for read and write requests. If 0, use + // go-fuse default (currently 128 kiB). + // This number is internally capped at MAX_KERNEL_WRITE (higher values don't make + // sense). + // + // Non-direct-io reads are mostly served via kernel readahead, which is + // additionally subject to the MaxReadAhead limit. + // + // Implementation notes: + // + // There's four values the Linux kernel looks at when deciding the request size: + // * MaxWrite, passed via InitOut.MaxWrite. Limits the WRITE size. + // * max_read, passed via a string mount option. Limits the READ size. + // go-fuse sets max_read equal to MaxWrite. + // You can see the current max_read value in /proc/self/mounts . + // * MaxPages, passed via InitOut.MaxPages. In Linux 4.20 and later, the value + // can go up to 1 MiB and go-fuse calculates the MaxPages value acc. + // to MaxWrite, rounding up. + // On older kernels, the value is fixed at 128 kiB and the + // passed value is ignored. No request can be larger than MaxPages, so + // READ and WRITE are effectively capped at MaxPages. + // * MaxReadAhead, passed via InitOut.MaxReadAhead. + MaxWrite int + + // MaxReadAhead is the max read ahead size to use. It controls how much data the + // kernel reads in advance to satisfy future read requests from applications. + // How much exactly is subject to clever heuristics in the kernel + // (see https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/readahead.c?h=v6.2-rc5#n375 + // if you are brave) and hence also depends on the kernel version. + // + // If 0, use kernel default. This number is capped at the kernel maximum + // (128 kiB on Linux) and cannot be larger than MaxWrite. + // + // MaxReadAhead only affects buffered reads (=non-direct-io), but even then, the + // kernel can and does send larger reads to satisfy read reqests from applications + // (up to MaxWrite or VM_READAHEAD_PAGES=128 kiB, whichever is less). + MaxReadAhead int + + // IgnoreSecurityLabels, if set, makes security related xattr + // requests return NO_DATA without passing through the + // user defined filesystem. You should only set this if you + // file system implements extended attributes, and you are not + // interested in security labels. + IgnoreSecurityLabels bool // ignoring labels should be provided as a fusermount mount option. + + // RememberInodes, if set, makes go-fuse never forget inodes. + // This may be useful for NFS. + RememberInodes bool + + // FsName is the name of the filesystem, shown in "df -T" + // and friends (as the first column, "Filesystem"). + FsName string + + // Name is the "fuse." suffix, shown in "df -T" and friends + // (as the second column, "Type") + Name string + + // SingleThreaded, if set, wraps the file system in a single-threaded + // locking wrapper. + SingleThreaded bool + + // DisableXAttrs, if set, returns ENOSYS for Getxattr, Setxattr and + // Removexattr calls, so the kernel does not issue any Xattr operations + // at all. + DisableXAttrs bool + + // Debug, if set, enables verbose debugging information. + Debug bool + + // Logger, if set, is an alternate log sink for debug statements. + // + // To increase signal/noise ratio Go-FUSE uses abbreviations in its debug log + // output. Here is how to read it: + // + // - `iX` means `inode X`; + // - `gX` means `generation X`; + // - `tA` and `tE` means timeout for attributes and directory entry correspondingly; + // - `[ +)` means data range from `` inclusive till `+` exclusive; + // - `Xb` means `X bytes`. + // - `pX` means the request originated from PID `x`. 0 means the request originated from the kernel. + // + // Every line is prefixed with either `rx ` (receive from kernel) or `tx ` (send to kernel) + // + // Example debug log output: + // + // rx 2: LOOKUP i1 [".wcfs"] 6b p5874 + // tx 2: OK, {i3 g2 tE=1s tA=1s {M040755 SZ=0 L=0 1000:1000 B0*0 i0:3 A 0.000000 M 0.000000 C 0.000000}} + // rx 3: LOOKUP i3 ["zurl"] 5b p5874 + // tx 3: OK, {i4 g3 tE=1s tA=1s {M0100644 SZ=33 L=1 1000:1000 B0*0 i0:4 A 0.000000 M 0.000000 C 0.000000}} + // rx 4: OPEN i4 {O_RDONLY,0x8000} p5874 + // tx 4: 38=function not implemented, {Fh 0 } + // rx 5: READ i4 {Fh 0 [0 +4096) L 0 RDONLY,0x8000} p5874 + // tx 5: OK, 33b data "file:///"... + // rx 6: GETATTR i4 {Fh 0} p5874 + // tx 6: OK, {tA=1s {M0100644 SZ=33 L=1 1000:1000 B0*0 i0:4 A 0.000000 M 0.000000 C 0.000000}} + // rx 7: FLUSH i4 {Fh 0} p5874 + // tx 7: OK + // rx 8: LOOKUP i1 ["head"] 5b p5874 + // tx 8: OK, {i5 g4 tE=1s tA=1s {M040755 SZ=0 L=0 1000:1000 B0*0 i0:5 A 0.000000 M 0.000000 C 0.000000}} + // rx 9: LOOKUP i5 ["bigfile"] 8b p5874 + // tx 9: OK, {i6 g5 tE=1s tA=1s {M040755 SZ=0 L=0 1000:1000 B0*0 i0:6 A 0.000000 M 0.000000 C 0.000000}} + // rx 10: FLUSH i4 {Fh 0} p5874 + // tx 10: OK + // rx 11: GETATTR i1 {Fh 0} p5874 + // tx 11: OK, {tA=1s {M040755 SZ=0 L=1 1000:1000 B0*0 i0:1 A 0.000000 M 0.000000 C 0.000000}} + Logger *log.Logger + + // EnableLocks, if set, asks the kernel to forward file locks to FUSE + // When used, you must implement the GetLk/SetLk/SetLkw methods. + EnableLocks bool + + // EnableSymlinkCaching, if set, makes the kernel cache all Readlink return values. + // The filesystem must use content notification to force the + // kernel to issue a new Readlink call. + EnableSymlinkCaching bool + + // ExplicitDataCacheControl, if set, asks the kernel not to do automatic + // data cache invalidation. The filesystem is fully responsible for + // invalidating data cache. + ExplicitDataCacheControl bool + + // SyncRead disables the CAP_ASYNC_READ capability. The + // kernel then only sends one read request per file handle at + // a time, and orders the requests by offset. This is useful + // if reading out of order or concurrently is expensive for + // (example: Amazon Cloud Drive). + // + // If unset, multiple concurrent reads may be issued to + // service userspace requests and kernel readahead. + // + // See the comment to FUSE_CAP_ASYNC_READ in + // https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h + // for more details. + SyncRead bool + + // DirectMount, if set, makes go-fuse first attempt to use syscall.Mount instead of + // fusermount to mount the filesystem. This will not update /etc/mtab + // but might be needed if fusermount is not available. + // Also, Server.Unmount will attempt syscall.Unmount before calling + // fusermount. + DirectMount bool + + // DirectMountStrict, if set, is like DirectMount but no fallback to fusermount is + // performed. If both DirectMount and DirectMountStrict are set, + // DirectMountStrict wins. + DirectMountStrict bool + + // DirectMountFlags are the mountflags passed to syscall.Mount. If zero, the + // default value used by fusermount are used: syscall.MS_NOSUID|syscall.MS_NODEV. + // + // If you actually *want* zero flags, pass syscall.MS_MGC_VAL, which is ignored + // by the kernel. See `man 2 mount` for details about MS_MGC_VAL. + DirectMountFlags uintptr + + // EnableAcl, if set, enables kernel ACL support. + // + // See the comments to FUSE_CAP_POSIX_ACL + // in https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h + // for details. + EnableAcl bool + + // DisableReadDirPlus, if set, disables the ReadDirPlus capability so + // ReadDir is used instead. Simple directory queries (i.e. 'ls' without + // '-l') can be faster with ReadDir, as no per-file stat calls are needed. + DisableReadDirPlus bool + + // DisableSplice, if set, disables splicing from files to the FUSE device. + DisableSplice bool + + // PanicHandler is called if an FS routine panics. The handler + // should return a nonzero status. If not set, the default is + // to print a stack trace and return EIO. + PanicHandler func(any) Status + + // MaxStackDepth is the maximum stacking depth for passthrough files. + // If unset, the default is 1. + MaxStackDepth int + + // IDMappedMount, if set, enables an ID-mapped mount if the Kernel supports + // it. + // + // An ID-mapped mount allows the device to be mounted on the system with the + // IDs remapped (via mount_setattr, move_mount syscalls) to those of the + // user on the local system. + // + // Enabling this flag automatically sets the "default_permissions" mount + // option. This is required by FUSE to delegate the UID/GID-based permission + // checks to the kernel. For requests that create new inodes, FUSE will send + // the mapped UID/GIDs. For all other requests, FUSE will send "-1". + IDMappedMount bool + + // DisabledCapabilities is a bitmask, containing capablities + // (the CAP_* bitmasks) that must be disabled for the entire + // mount. + DisabledCapabilities uint64 + + // ExtraCapabilities is a bitmask of capabilities which + // must be enabled in addition to the defaults. + ExtraCapabilities uint64 +} + +// RawFileSystem is an interface close to the FUSE wire protocol. +// +// Unless you really know what you are doing, you should not implement +// this, but rather the interfaces associated with +// fs.InodeEmbedder. The details of getting interactions with open +// files, renames, and threading right etc. are somewhat tricky and +// not very interesting. +// +// Each FUSE request results in a corresponding method called by Server. +// Several calls may be made simultaneously, because the server typically calls +// each method in separate goroutine. +// +// A null implementation is provided by NewDefaultRawFileSystem. +// +// After a successful FUSE API call returns, you may not read input or +// write output data: for performance reasons, memory is reused for +// following requests, and reading/writing the request data will lead +// to race conditions. If you spawn a background routine from a FUSE +// API call, any incoming request data it wants to reference should be +// copied over. +// +// If a FS operation is interrupted, the `cancel` channel is +// closed. The fileystem can honor this request by returning EINTR. In +// this case, the outstanding request data is not reused. Interrupts +// occur if the process accessing the file system receives any signal +// that is not ignored. In particular, the Go runtime uses signals to +// manage goroutine preemption, so Go programs under load naturally +// generate interupt opcodes when they access a FUSE filesystem. +// ReplyAttemptObserver is called after the server has attempted to write a +// request's reply to the FUSE device. A failed write is still an attempt. +// Requests whose replies are suppressed do not trigger this callback. +type ReplyAttemptObserver interface { + OnReplyAttempt(unique uint64, opcode uint32, replyStatus Status, writeStatus Status) +} + +// ReplySuppressedObserver is called after the handler returns when the FUSE +// connection will not send a reply for the request. +type ReplySuppressedObserver interface { + OnReplySuppressed(unique uint64, opcode uint32, replyStatus Status) +} + +type RawFileSystem interface { + String() string + + // If called, provide debug output through the log package. + SetDebug(debug bool) + + // Lookup is called by the kernel when the VFS wants to know + // about a file inside a directory. Many lookup calls can + // occur in parallel, but only one call happens for each (dir, + // name) pair. + Lookup(cancel <-chan struct{}, header *InHeader, name string, out *EntryOut) (status Status) + + // Forget is called when the kernel discards entries from its + // dentry cache. This happens on unmount, and when the kernel + // is short on memory. Since it is not guaranteed to occur at + // any moment, and since there is no return value, Forget + // should not do I/O, as there is no channel to report back + // I/O errors. + Forget(nodeid, nlookup uint64) + + // Attributes. + GetAttr(cancel <-chan struct{}, input *GetAttrIn, out *AttrOut) (code Status) + SetAttr(cancel <-chan struct{}, input *SetAttrIn, out *AttrOut) (code Status) + + // Modifying structure. + Mknod(cancel <-chan struct{}, input *MknodIn, name string, out *EntryOut) (code Status) + Mkdir(cancel <-chan struct{}, input *MkdirIn, name string, out *EntryOut) (code Status) + Unlink(cancel <-chan struct{}, header *InHeader, name string) (code Status) + Rmdir(cancel <-chan struct{}, header *InHeader, name string) (code Status) + Rename(cancel <-chan struct{}, input *RenameIn, oldName string, newName string) (code Status) + Link(cancel <-chan struct{}, input *LinkIn, filename string, out *EntryOut) (code Status) + + Symlink(cancel <-chan struct{}, header *InHeader, pointedTo string, linkName string, out *EntryOut) (code Status) + Readlink(cancel <-chan struct{}, header *InHeader) (out []byte, code Status) + Access(cancel <-chan struct{}, input *AccessIn) (code Status) + + // Extended attributes. + + // GetXAttr reads an extended attribute, and should return the + // number of bytes. If the buffer is too small, return ERANGE, + // with the required buffer size. + GetXAttr(cancel <-chan struct{}, header *InHeader, attr string, dest []byte) (sz uint32, code Status) + + // ListXAttr lists extended attributes as '\0' delimited byte + // slice, and return the number of bytes. If the buffer is too + // small, return ERANGE, with the required buffer size. + ListXAttr(cancel <-chan struct{}, header *InHeader, dest []byte) (uint32, Status) + + // SetAttr writes an extended attribute. + SetXAttr(cancel <-chan struct{}, input *SetXAttrIn, attr string, data []byte) Status + + // RemoveXAttr removes an extended attribute. + RemoveXAttr(cancel <-chan struct{}, header *InHeader, attr string) (code Status) + + // File handling. + Create(cancel <-chan struct{}, input *CreateIn, name string, out *CreateOut) (code Status) + Tmpfile(cancel <-chan struct{}, input *CreateIn, out *CreateOut) (code Status) + Open(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) + Read(cancel <-chan struct{}, input *ReadIn, buf []byte) (ReadResult, Status) + Lseek(cancel <-chan struct{}, in *LseekIn, out *LseekOut) Status + + // File locking + GetLk(cancel <-chan struct{}, input *LkIn, out *LkOut) (code Status) + SetLk(cancel <-chan struct{}, input *LkIn) (code Status) + SetLkw(cancel <-chan struct{}, input *LkIn) (code Status) + + Release(cancel <-chan struct{}, input *ReleaseIn) + Write(cancel <-chan struct{}, input *WriteIn, data []byte) (written uint32, code Status) + + // Writev is called for WRITE. On ENOSYS, Write is called. + Writev(cancel <-chan struct{}, input *WriteIn, data [][]byte) (written uint32, code Status) + CopyFileRange(cancel <-chan struct{}, input *CopyFileRangeIn) (written uint32, code Status) + Ioctl(cancel <-chan struct{}, input *IoctlIn, inbuf []byte, output *IoctlOut, outbuf []byte) (code Status) + + Flush(cancel <-chan struct{}, input *FlushIn) Status + Fsync(cancel <-chan struct{}, input *FsyncIn) (code Status) + Fallocate(cancel <-chan struct{}, input *FallocateIn) (code Status) + + // Directory handling + OpenDir(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) + ReadDir(cancel <-chan struct{}, input *ReadIn, out *DirEntryList) Status + ReadDirPlus(cancel <-chan struct{}, input *ReadIn, out *DirEntryList) Status + ReleaseDir(input *ReleaseIn) + FsyncDir(cancel <-chan struct{}, input *FsyncIn) (code Status) + + StatFs(cancel <-chan struct{}, input *InHeader, out *StatfsOut) (code Status) + + Statx(cancel <-chan struct{}, input *StatxIn, out *StatxOut) (code Status) + // This is called on processing the first request. The + // filesystem implementation can use the server argument to + // talk back to the kernel (through notify methods). + Init(*Server) + + // Called after processing the last request. + OnUnmount() +} diff --git a/third_party/go-fuse/fuse/attr.go b/third_party/go-fuse/fuse/attr.go new file mode 100644 index 000000000..99d171593 --- /dev/null +++ b/third_party/go-fuse/fuse/attr.go @@ -0,0 +1,79 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" + "syscall" + "time" +) + +func (a *Attr) IsFifo() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFIFO } + +// IsChar reports whether the FileInfo describes a character special file. +func (a *Attr) IsChar() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFCHR } + +// IsDir reports whether the FileInfo describes a directory. +func (a *Attr) IsDir() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFDIR } + +// IsBlock reports whether the FileInfo describes a block special file. +func (a *Attr) IsBlock() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFBLK } + +// IsRegular reports whether the FileInfo describes a regular file. +func (a *Attr) IsRegular() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFREG } + +// IsSymlink reports whether the FileInfo describes a symbolic link. +func (a *Attr) IsSymlink() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFLNK } + +// IsSocket reports whether the FileInfo describes a socket. +func (a *Attr) IsSocket() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFSOCK } + +func (a *Attr) SetTimes(access *time.Time, mod *time.Time, chstatus *time.Time) { + if access != nil { + a.Atime = uint64(access.Unix()) + a.Atimensec = uint32(access.Nanosecond()) + } + if mod != nil { + a.Mtime = uint64(mod.Unix()) + a.Mtimensec = uint32(mod.Nanosecond()) + } + if chstatus != nil { + a.Ctime = uint64(chstatus.Unix()) + a.Ctimensec = uint32(chstatus.Nanosecond()) + } +} + +func (a *Attr) ChangeTime() time.Time { + return time.Unix(int64(a.Ctime), int64(a.Ctimensec)) +} + +func (a *Attr) AccessTime() time.Time { + return time.Unix(int64(a.Atime), int64(a.Atimensec)) +} + +func (a *Attr) ModTime() time.Time { + return time.Unix(int64(a.Mtime), int64(a.Mtimensec)) +} + +func ToStatT(f os.FileInfo) *syscall.Stat_t { + s, _ := f.Sys().(*syscall.Stat_t) + if s != nil { + return s + } + return nil +} + +func ToAttr(f os.FileInfo) *Attr { + if f == nil { + return nil + } + s := ToStatT(f) + if s != nil { + a := &Attr{} + a.FromStat(s) + return a + } + return nil +} diff --git a/third_party/go-fuse/fuse/attr_linux.go b/third_party/go-fuse/fuse/attr_linux.go new file mode 100644 index 000000000..afcf21720 --- /dev/null +++ b/third_party/go-fuse/fuse/attr_linux.go @@ -0,0 +1,51 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +func (a *Attr) FromStat(s *syscall.Stat_t) { + a.Ino = uint64(s.Ino) + a.Size = uint64(s.Size) + a.Blocks = uint64(s.Blocks) + a.Atime = uint64(s.Atim.Sec) + a.Atimensec = uint32(s.Atim.Nsec) + a.Mtime = uint64(s.Mtim.Sec) + a.Mtimensec = uint32(s.Mtim.Nsec) + a.Ctime = uint64(s.Ctim.Sec) + a.Ctimensec = uint32(s.Ctim.Nsec) + a.Mode = s.Mode + a.Nlink = uint32(s.Nlink) + a.Uid = uint32(s.Uid) + a.Gid = uint32(s.Gid) + a.Rdev = uint32(s.Rdev) + a.Blksize = uint32(s.Blksize) +} + +func (a *Statx) FromStatx(s *unix.Statx_t) { + a.Ino = uint64(s.Ino) + a.Size = uint64(s.Size) + a.Blocks = uint64(s.Blocks) + a.Atime.FromStatxTimestamp(&s.Atime) + a.Btime.FromStatxTimestamp(&s.Btime) + a.Ctime.FromStatxTimestamp(&s.Ctime) + a.Mtime.FromStatxTimestamp(&s.Mtime) + a.Mode = s.Mode + a.Nlink = uint32(s.Nlink) + a.Uid = uint32(s.Uid) + a.Gid = uint32(s.Gid) + a.Blksize = uint32(s.Blksize) + a.AttributesMask = s.Attributes_mask + a.Mask = s.Mask + a.Attributes = s.Attributes + a.RdevMinor = s.Rdev_minor + a.RdevMajor = s.Rdev_major + a.DevMajor = s.Dev_major + a.DevMinor = s.Dev_minor +} diff --git a/third_party/go-fuse/fuse/attr_unix.go b/third_party/go-fuse/fuse/attr_unix.go new file mode 100644 index 000000000..4b479fd6a --- /dev/null +++ b/third_party/go-fuse/fuse/attr_unix.go @@ -0,0 +1,29 @@ +//go:build !linux + +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" +) + +func (a *Attr) FromStat(s *syscall.Stat_t) { + a.Ino = uint64(s.Ino) + a.Size = uint64(s.Size) + a.Blocks = uint64(s.Blocks) + a.Atime = uint64(s.Atimespec.Sec) + a.Atimensec = uint32(s.Atimespec.Nsec) + a.Mtime = uint64(s.Mtimespec.Sec) + a.Mtimensec = uint32(s.Mtimespec.Nsec) + a.Ctime = uint64(s.Ctimespec.Sec) + a.Ctimensec = uint32(s.Ctimespec.Nsec) + a.Mode = uint32(s.Mode) + a.Nlink = uint32(s.Nlink) + a.Uid = uint32(s.Uid) + a.Gid = uint32(s.Gid) + a.Rdev = uint32(s.Rdev) + a.Blksize = uint32(s.Blksize) +} diff --git a/third_party/go-fuse/fuse/bufferpool.go b/third_party/go-fuse/fuse/bufferpool.go new file mode 100644 index 000000000..f3350d97b --- /dev/null +++ b/third_party/go-fuse/fuse/bufferpool.go @@ -0,0 +1,79 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" + "sync" +) + +// bufferPool implements explicit memory management. It is used for +// minimizing the GC overhead of communicating with the kernel. +type bufferPool struct { + lock sync.Mutex + + // For each page size multiple a list of slice pointers. + buffersBySize []*sync.Pool + + // Number of outstanding allocations. Used for testing. + countersBySize []int +} + +var pageSize = os.Getpagesize() + +func (p *bufferPool) counters() []int { + p.lock.Lock() + defer p.lock.Unlock() + + d := make([]int, len(p.countersBySize)) + copy(d, p.countersBySize) + return d +} + +func (p *bufferPool) getPool(pageCount int, delta int) *sync.Pool { + p.lock.Lock() + defer p.lock.Unlock() + for len(p.buffersBySize) <= pageCount { + p.buffersBySize = append(p.buffersBySize, nil) + p.countersBySize = append(p.countersBySize, 0) + } + if p.buffersBySize[pageCount] == nil { + p.buffersBySize[pageCount] = &sync.Pool{ + New: func() any { return make([]byte, pageSize*pageCount) }, + } + } + p.countersBySize[pageCount] += delta + return p.buffersBySize[pageCount] +} + +// AllocBuffer creates a buffer of at least the given size. After use, +// it should be deallocated with FreeBuffer(). +func (p *bufferPool) AllocBuffer(size uint32) []byte { + sz := max(int(size), pageSize) + + if sz%pageSize != 0 { + sz += pageSize + } + pages := sz / pageSize + + b := p.getPool(pages, 1).Get().([]byte) + return b[:size] +} + +// FreeBuffer takes back a buffer if it was allocated through +// AllocBuffer. It is not an error to call FreeBuffer() on a slice +// obtained elsewhere. +func (p *bufferPool) FreeBuffer(slice []byte) { + if slice == nil { + return + } + if cap(slice)%pageSize != 0 || cap(slice) == 0 { + return + } + pages := cap(slice) / pageSize + slice = slice[:cap(slice)] + + p.getPool(pages, -1).Put(slice) +} diff --git a/third_party/go-fuse/fuse/constants.go b/third_party/go-fuse/fuse/constants.go new file mode 100644 index 000000000..06d040231 --- /dev/null +++ b/third_party/go-fuse/fuse/constants.go @@ -0,0 +1,44 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" + "syscall" +) + +const ( + FUSE_ROOT_ID = 1 + + FUSE_UNKNOWN_INO = 0xffffffff + + CUSE_UNRESTRICTED_IOCTL = (1 << 0) + + FUSE_LK_FLOCK = (1 << 0) + + FUSE_RELEASE_FLUSH = (1 << 0) + FUSE_RELEASE_FLOCK_UNLOCK = (1 << 1) + + FUSE_IOCTL_MAX_IOV = 256 + + FUSE_POLL_SCHEDULE_NOTIFY = (1 << 0) + + CUSE_INIT_INFO_MAX = 4096 + + S_IFDIR = syscall.S_IFDIR + S_IFREG = syscall.S_IFREG + S_IFLNK = syscall.S_IFLNK + S_IFIFO = syscall.S_IFIFO + + CUSE_INIT = 4096 + + O_ANYWRITE = uint32(os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_CREATE | os.O_TRUNC) + + // FMODE_EXEC is a passed to OPEN requests if the file is + // being executed. + FMODE_EXEC = 0x20 + + logicalBlockSize = 4096 +) diff --git a/third_party/go-fuse/fuse/constants_freebsd.go b/third_party/go-fuse/fuse/constants_freebsd.go new file mode 100644 index 000000000..5fb606919 --- /dev/null +++ b/third_party/go-fuse/fuse/constants_freebsd.go @@ -0,0 +1,9 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +// arbitrary values +const syscall_O_LARGEFILE = 1 << 29 +const syscall_O_NOATIME = 1 << 30 diff --git a/third_party/go-fuse/fuse/constants_linux.go b/third_party/go-fuse/fuse/constants_linux.go new file mode 100644 index 000000000..29455e4f6 --- /dev/null +++ b/third_party/go-fuse/fuse/constants_linux.go @@ -0,0 +1,12 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" +) + +const syscall_O_LARGEFILE = syscall.O_LARGEFILE +const syscall_O_NOATIME = syscall.O_NOATIME diff --git a/third_party/go-fuse/fuse/context.go b/third_party/go-fuse/fuse/context.go new file mode 100644 index 000000000..f8e6edf4e --- /dev/null +++ b/third_party/go-fuse/fuse/context.go @@ -0,0 +1,61 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "context" + "time" +) + +// Context passes along cancelation signal and request data (PID, GID, +// UID). The name of this class predates the standard "context" +// package from Go, but it does implement the context.Context +// interface. +// +// When a FUSE request is canceled, and the file system chooses to honor +// the cancellation, the response should be EINTR. +type Context struct { + Caller + Cancel <-chan struct{} +} + +func (c *Context) Deadline() (time.Time, bool) { + return time.Time{}, false +} + +func (c *Context) Done() <-chan struct{} { + return c.Cancel +} + +func (c *Context) Err() error { + select { + case <-c.Cancel: + return context.Canceled + default: + return nil + } +} + +type callerKeyType struct{} + +var callerKey callerKeyType + +func FromContext(ctx context.Context) (*Caller, bool) { + v, ok := ctx.Value(callerKey).(*Caller) + return v, ok +} + +func NewContext(ctx context.Context, caller *Caller) context.Context { + return context.WithValue(ctx, callerKey, caller) +} + +func (c *Context) Value(key any) any { + if key == callerKey { + return &c.Caller + } + return nil +} + +var _ = context.Context((*Context)(nil)) diff --git a/third_party/go-fuse/fuse/defaultraw.go b/third_party/go-fuse/fuse/defaultraw.go new file mode 100644 index 000000000..8d5882cc3 --- /dev/null +++ b/third_party/go-fuse/fuse/defaultraw.go @@ -0,0 +1,188 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" +) + +// NewDefaultRawFileSystem returns ENOSYS (not implemented) for all +// operations. +func NewDefaultRawFileSystem() RawFileSystem { + return (*defaultRawFileSystem)(nil) +} + +type defaultRawFileSystem struct{} + +func (fs *defaultRawFileSystem) Init(*Server) { +} + +func (fs *defaultRawFileSystem) OnUnmount() { + +} + +func (fs *defaultRawFileSystem) String() string { + return os.Args[0] +} + +func (fs *defaultRawFileSystem) SetDebug(dbg bool) { +} + +func (fs *defaultRawFileSystem) StatFs(cancel <-chan struct{}, header *InHeader, out *StatfsOut) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Lookup(cancel <-chan struct{}, header *InHeader, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Forget(nodeID, nlookup uint64) { +} + +func (fs *defaultRawFileSystem) GetAttr(cancel <-chan struct{}, input *GetAttrIn, out *AttrOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Open(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) { + return OK +} + +func (fs *defaultRawFileSystem) SetAttr(cancel <-chan struct{}, input *SetAttrIn, out *AttrOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Readlink(cancel <-chan struct{}, header *InHeader) (out []byte, code Status) { + return nil, ENOSYS +} + +func (fs *defaultRawFileSystem) Mknod(cancel <-chan struct{}, input *MknodIn, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Mkdir(cancel <-chan struct{}, input *MkdirIn, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Unlink(cancel <-chan struct{}, header *InHeader, name string) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Rmdir(cancel <-chan struct{}, header *InHeader, name string) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Symlink(cancel <-chan struct{}, header *InHeader, pointedTo string, linkName string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Rename(cancel <-chan struct{}, input *RenameIn, oldName string, newName string) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Link(cancel <-chan struct{}, input *LinkIn, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) GetXAttr(cancel <-chan struct{}, header *InHeader, attr string, dest []byte) (size uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) SetXAttr(cancel <-chan struct{}, input *SetXAttrIn, attr string, data []byte) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ListXAttr(cancel <-chan struct{}, header *InHeader, dest []byte) (n uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) RemoveXAttr(cancel <-chan struct{}, header *InHeader, attr string) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Access(cancel <-chan struct{}, input *AccessIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Create(cancel <-chan struct{}, input *CreateIn, name string, out *CreateOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Tmpfile(cancel <-chan struct{}, input *CreateIn, out *CreateOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) OpenDir(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Read(cancel <-chan struct{}, input *ReadIn, buf []byte) (ReadResult, Status) { + return nil, ENOSYS +} + +func (fs *defaultRawFileSystem) GetLk(cancel <-chan struct{}, in *LkIn, out *LkOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) SetLk(cancel <-chan struct{}, in *LkIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) SetLkw(cancel <-chan struct{}, in *LkIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Release(cancel <-chan struct{}, input *ReleaseIn) { +} + +func (fs *defaultRawFileSystem) Write(cancel <-chan struct{}, input *WriteIn, data []byte) (written uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) Writev(cancel <-chan struct{}, input *WriteIn, data [][]byte) (written uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) Flush(cancel <-chan struct{}, input *FlushIn) Status { + return OK +} + +func (fs *defaultRawFileSystem) Fsync(cancel <-chan struct{}, input *FsyncIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ReadDir(cancel <-chan struct{}, input *ReadIn, l *DirEntryList) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ReadDirPlus(cancel <-chan struct{}, input *ReadIn, l *DirEntryList) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ReleaseDir(input *ReleaseIn) { +} + +func (fs *defaultRawFileSystem) FsyncDir(cancel <-chan struct{}, input *FsyncIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Fallocate(cancel <-chan struct{}, in *FallocateIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) CopyFileRange(cancel <-chan struct{}, input *CopyFileRangeIn) (written uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) Ioctl(cancel <-chan struct{}, input *IoctlIn, inbuf []byte, output *IoctlOut, outbuf []byte) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Lseek(cancel <-chan struct{}, in *LseekIn, out *LseekOut) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Statx(cancel <-chan struct{}, input *StatxIn, out *StatxOut) (code Status) { + return ENOSYS +} diff --git a/third_party/go-fuse/fuse/direntry.go b/third_party/go-fuse/fuse/direntry.go new file mode 100644 index 000000000..50052ff22 --- /dev/null +++ b/third_party/go-fuse/fuse/direntry.go @@ -0,0 +1,264 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "unsafe" +) + +const direntSize = int(unsafe.Sizeof(_Dirent{})) + +// DirEntry is a type for PathFileSystem and NodeFileSystem to return +// directory contents in. +type DirEntry struct { + // Mode is the file's mode. Only the high bits (eg. S_IFDIR) + // are considered. + Mode uint32 + + // Name is the basename of the file in the directory. + Name string + + // Ino is the inode number. + Ino uint64 + + // Off is the offset in the directory stream. The offset is + // thought to be after the entry. + Off uint64 +} + +func (d *DirEntry) String() string { + return fmt.Sprintf("%d: %q ino=%d (%o)", d.Off, d.Name, d.Ino, d.Mode) +} + +// Parse reads an entry from getdents(2) buffer. It returns the number +// of bytes consumed. +func (d *DirEntry) Parse(buf []byte) int { + // We can't use syscall.Dirent here, because it declares a + // [256]byte name, which may run beyond the end of ds.todo. + // when that happens in the race detector, it causes a panic + // "converted pointer straddles multiple allocations" + de := (*dirent)(unsafe.Pointer(&buf[0])) + off := unsafe.Offsetof(dirent{}.Name) + nameBytes := buf[off : off+uintptr(de.nameLength())] + n := de.Reclen + + l := bytes.IndexByte(nameBytes, 0) + if l >= 0 { + nameBytes = nameBytes[:l] + } + *d = DirEntry{ + Ino: de.Ino, + Mode: (uint32(de.Type) << 12), + Name: string(nameBytes), + Off: uint64(de.Off), + } + return int(n) +} + +// DirEntryList holds the return value for READDIR and READDIRPLUS +// opcodes. +type DirEntryList struct { + buf []byte + // capacity of the underlying buffer + size int + + // Offset holds the offset for the next entry to be added. It + // is the offset supplied at construction time, or the Offset + // of the last DirEntry that was added. + Offset uint64 + + // pointer to the last serialized _Dirent. Used by FixMode(). + lastDirent *_Dirent + + readdirPlus bool +} + +func (l *DirEntryList) setReaddirPlus(plus bool) { + if len(l.buf) > 0 && l.readdirPlus != plus { + panic("DirEntryList: cannot mix AddDirEntry and AddDirLookupEntry") + } + l.readdirPlus = plus +} + +// NewDirEntryList creates a DirEntryList with the given data buffer +// and offset. +func NewDirEntryList(data []byte, off uint64) *DirEntryList { + return &DirEntryList{ + buf: data[:0], + size: len(data), + Offset: off, + } +} + +// AddDirEntry tries to add an entry, and reports whether it +// succeeded. If adding a 0 offset entry, the offset is taken to be +// the last offset + 1. +func (l *DirEntryList) AddDirEntry(e DirEntry) bool { + // TODO: take pointer arg, merge with AddDirLookupEntry. + l.setReaddirPlus(false) + return l.addDirEntry(&e, 0) +} + +func (l *DirEntryList) addDirEntry(e *DirEntry, prefix int) bool { + if e.Ino == 0 { + e.Ino = FUSE_UNKNOWN_INO + } + if e.Off == 0 { + e.Off = l.Offset + 1 + } + padding := (8 - len(e.Name)&7) & 7 + delta := padding + direntSize + len(e.Name) + prefix + oldLen := len(l.buf) + newLen := delta + oldLen + + if newLen > l.size { + return false + } + l.buf = l.buf[:newLen] + oldLen += prefix + dirent := (*_Dirent)(unsafe.Pointer(&l.buf[oldLen])) + dirent.Off = e.Off + dirent.Ino = e.Ino + dirent.NameLen = uint32(len(e.Name)) + dirent.Typ = modeToType(e.Mode) + oldLen += direntSize + copy(l.buf[oldLen:], e.Name) + oldLen += len(e.Name) + + // The buffer is recycled, so the padding may hold data from + // a previous request. + clear(l.buf[oldLen : oldLen+padding]) + l.Offset = dirent.Off + return true +} + +// Add adds a direntry to the DirEntryList, returning wheither it +// succeeded. Prefix is the amount of padding to add before the DirEntry. +// +// Deprecated: use AddDirLookupEntry or AddDirEntry. +func (l *DirEntryList) Add(prefix int, name string, inode uint64, mode uint32) bool { + // TODO: remove. + e := DirEntry{ + Name: name, + Mode: mode, + Off: l.Offset + 1, + Ino: inode, + } + l.setReaddirPlus(prefix != 0) + return l.addDirEntry(&e, prefix) +} + +// AddDirLookupEntry is used for ReadDirPlus. If reserves and zeroes +// space for an EntryOut struct and serializes the DirEntry. If adding +// a 0 offset entry, the offset is taken to be the last offset + 1. +// If the entry does not fit, it returns nil. +func (l *DirEntryList) AddDirLookupEntry(e DirEntry) *EntryOut { + // The resulting READDIRPLUS output buffer looks like this in memory: + // 1) EntryOut{} + // 2) _Dirent{} + // 3) Name (null-terminated) + // 4) Padding to align to 8 bytes + // [repeat] + + // TODO: should take pointer as argument. + l.setReaddirPlus(true) + oldLen := len(l.buf) + ok := l.addDirEntry(&e, entryOutSize) + if !ok { + return nil + } + l.lastDirent = (*_Dirent)(unsafe.Pointer(&l.buf[oldLen+entryOutSize])) + entryOut := (*EntryOut)(unsafe.Pointer(&l.buf[oldLen])) + *entryOut = EntryOut{} + return entryOut +} + +// modeToType converts a file *mode* (as used in syscall.Stat_t.Mode) +// to a file *type* (as used in _Dirent.Typ). +// Equivalent to IFTODT() in libc (see man 5 dirent). +func modeToType(mode uint32) uint32 { + return (mode & 0170000) >> 12 +} + +// FixMode overrides the file mode of the last direntry that was added. This can +// be needed when a directory changes while READDIRPLUS is running. +// Only the file type bits of mode are considered, the rest is masked out. +func (l *DirEntryList) FixMode(mode uint32) { + if l.lastDirent == nil { + return + } + l.lastDirent.Typ = modeToType(mode) +} + +// Bytes returns the serialized entries. +func (l *DirEntryList) Bytes() []byte { + return l.buf +} + +const entryOutSize = int(unsafe.Sizeof(EntryOut{})) + +// ParseDirEntries parses READDIR output. +func ParseDirEntries(buf []byte) ([]DirEntry, error) { + var result []DirEntry + for len(buf) > 0 { + var e DirEntry + n, err := parseDirent(buf, &e) + if err != nil { + return nil, err + } + result = append(result, e) + buf = buf[n:] + } + return result, nil +} + +// ParseDirLookupEntries parses READDIRPLUS output. +func ParseDirLookupEntries(buf []byte) ([]DirEntry, []EntryOut, error) { + var entries []DirEntry + var outs []EntryOut + for len(buf) > 0 { + if len(buf) < entryOutSize { + return nil, nil, fmt.Errorf("short EntryOut: %d bytes", len(buf)) + } + var out EntryOut + copy(unsafe.Slice((*byte)(unsafe.Pointer(&out)), entryOutSize), buf) + buf = buf[entryOutSize:] + + var e DirEntry + n, err := parseDirent(buf, &e) + if err != nil { + return nil, nil, err + } + entries = append(entries, e) + outs = append(outs, out) + buf = buf[n:] + } + return entries, outs, nil +} + +func parseDirent(buf []byte, e *DirEntry) (int, error) { + if len(buf) < direntSize { + return 0, fmt.Errorf("short dirent: %d bytes", len(buf)) + } + var d _Dirent + copy(unsafe.Slice((*byte)(unsafe.Pointer(&d)), direntSize), buf) + nameEnd := direntSize + int(d.NameLen) + if nameEnd > len(buf) { + return 0, fmt.Errorf("dirent name length %d exceeds buffer (%d bytes)", d.NameLen, len(buf)) + } + n := (nameEnd + 7) &^ 7 + if n > len(buf) { + return 0, fmt.Errorf("dirent padding exceeds buffer: need %d, have %d bytes", n, len(buf)) + } + *e = DirEntry{ + Ino: d.Ino, + Off: d.Off, + Mode: d.Typ << 12, + Name: string(buf[direntSize:nameEnd]), + } + return n, nil +} diff --git a/third_party/go-fuse/fuse/direntry_darwin.go b/third_party/go-fuse/fuse/direntry_darwin.go new file mode 100644 index 000000000..1da5b004c --- /dev/null +++ b/third_party/go-fuse/fuse/direntry_darwin.go @@ -0,0 +1,19 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +// Like syscall.Dirent, but without the [256]byte name. +type dirent struct { + Ino uint64 + Off int64 + Reclen uint16 + Namlen uint16 + Type uint8 + Name [1]uint8 // align to 4 bytes for 32 bits. +} + +func (de *dirent) nameLength() int { + return int(de.Namlen) +} diff --git a/third_party/go-fuse/fuse/direntry_freebsd.go b/third_party/go-fuse/fuse/direntry_freebsd.go new file mode 100644 index 000000000..25951e1a8 --- /dev/null +++ b/third_party/go-fuse/fuse/direntry_freebsd.go @@ -0,0 +1,20 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +type dirent struct { + Ino uint64 + Off int64 + Reclen uint16 + Type uint8 + Pad0 uint8 + Namlen uint16 + Pad1 uint16 + Name [1]int8 +} + +func (de *dirent) nameLength() int { + return int(de.Namlen) +} diff --git a/third_party/go-fuse/fuse/direntry_linux.go b/third_party/go-fuse/fuse/direntry_linux.go new file mode 100644 index 000000000..82d5a17c1 --- /dev/null +++ b/third_party/go-fuse/fuse/direntry_linux.go @@ -0,0 +1,22 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "unsafe" + +// Like syscall.Dirent, but without the [256]byte name. This is +// equivalent to the linux_dirent64 struct, returned from the +// getdents64 syscall. +type dirent struct { + Ino uint64 + Off int64 + Reclen uint16 + Type uint8 + Name [1]uint8 // align to 4 bytes for 32 bits. +} + +func (de *dirent) nameLength() int { + return int(de.Reclen) - int(unsafe.Offsetof(dirent{}.Name)) +} diff --git a/third_party/go-fuse/fuse/ext.go b/third_party/go-fuse/fuse/ext.go new file mode 100644 index 000000000..9b199e14d --- /dev/null +++ b/third_party/go-fuse/fuse/ext.go @@ -0,0 +1,114 @@ +// Copyright 2026 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "encoding/binary" + "fmt" + "strings" +) + +// Request extensions; see struct fuse_ext_header in linux/fuse.h. +const ( + _MAX_NR_SECCTX = 31 + _EXT_GROUPS = 32 + + sizeOfExtHeader = 8 + sizeOfSecCtx = 8 +) + +type secCtx struct { + name []byte + value []byte +} + +type requestExt struct { + suppGroups []uint32 + secctx []secCtx +} + +func (e *requestExt) String() string { + var parts []string + if len(e.suppGroups) > 0 { + parts = append(parts, fmt.Sprintf("groups=%v", e.suppGroups)) + } + if len(e.secctx) > 0 { + var ctxs []string + for _, c := range e.secctx { + ctxs = append(ctxs, fmt.Sprintf("%s:%q", c.name, bytes.TrimRight(c.value, "\x00"))) + } + parts = append(parts, "secctx=["+strings.Join(ctxs, " ")+"]") + } + return "ext{" + strings.Join(parts, " ") + "}" +} + +func align8(n int) int { + return (n + 7) &^ 7 +} + +// parse parses b into e, reusing e's slices. +func (e *requestExt) parse(b []byte) Status { + clear(e.secctx) + e.secctx = e.secctx[:0] + e.suppGroups = e.suppGroups[:0] + for len(b) > 0 { + if len(b) < sizeOfExtHeader { + return EIO + } + size := binary.NativeEndian.Uint32(b) + typ := binary.NativeEndian.Uint32(b[4:]) + if size < sizeOfExtHeader || size%8 != 0 || uint64(size) > uint64(len(b)) { + return EIO + } + rec := b[sizeOfExtHeader:size] + b = b[size:] + + switch { + case typ <= _MAX_NR_SECCTX: + if !e.parseSecCtx(rec, int(typ)) { + return EIO + } + case typ == _EXT_GROUPS: + if len(rec) < 4 { + return EIO + } + nr := binary.NativeEndian.Uint32(rec) + rec = rec[4:] + if uint64(nr) > uint64(len(rec)/4) { + return EIO + } + for i := 0; i < int(nr); i++ { + e.suppGroups = append(e.suppGroups, binary.NativeEndian.Uint32(rec[4*i:])) + } + } + } + return OK +} + +// Each context is fuse_secctx, name\0, value, padded to 8 bytes. +func (e *requestExt) parseSecCtx(rec []byte, nr int) bool { + for i := 0; i < nr; i++ { + if len(rec) < sizeOfSecCtx { + return false + } + valSize := binary.NativeEndian.Uint32(rec) + rest := rec[sizeOfSecCtx:] + nul := bytes.IndexByte(rest, 0) + if nul < 0 { + return false + } + name := rest[:nul] + rest = rest[nul+1:] + if uint64(valSize) > uint64(len(rest)) { + return false + } + e.secctx = append(e.secctx, secCtx{name: name, value: rest[:valSize]}) + + used := sizeOfSecCtx + nul + 1 + int(valSize) + rec = rec[min(align8(used), len(rec)):] + } + return true +} diff --git a/third_party/go-fuse/fuse/fusefd.go b/third_party/go-fuse/fuse/fusefd.go new file mode 100644 index 000000000..bb11b2ca5 --- /dev/null +++ b/third_party/go-fuse/fuse/fusefd.go @@ -0,0 +1,253 @@ +// Copyright 2026 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "log" + "os" + "sync" + "syscall" + "unsafe" +) + +// fuseFD owns the per-FUSE-fd state: the fd itself, the read-side +// bookkeeping (reqReaders, inflightRequestBytes), and the WaitGroup +// tracking reader goroutines on this fd. The pools are shared with the +// owning Server via pointers, and a back pointer to the Server gives the +// per-fd methods access to shared configuration and callbacks. +// +// Today a Server has a single fuseFD; this struct exists so that adding +// support for FUSE_DEV_IOC_CLONE'd fds is a matter of widening the field +// to a slice. +type fuseFD struct { + server *Server + + // I/O with kernel and daemon. file owns the FUSE fd; conn is its + // RawConn. Running syscalls through conn holds a reference on the + // fd, so concurrent writers and close() are safe against the fd + // number being reused without any explicit locking. + file *os.File + conn syscall.RawConn + + reqMu sync.Mutex + reqReaders int + inflightRequestBytes int + + // loops tracks reader goroutines servicing fd. + loops sync.WaitGroup + + // Shared pools owned by Server. + reqPool *sync.Pool + readPool *sync.Pool + buffers *bufferPool + + // Accounting constants, set once at server construction. + reqAllocBytes int + readBufBytes int +} + +// newFuseFD returns a fuseFD bound to ms and ready to read from fd. +// Ownership of fd passes to the returned fuseFD's *os.File. All shared +// state (pools, buffer pool, accounting constants) is wired up here. +func (ms *Server) newFuseFD(fd int) (*fuseFD, error) { + file := os.NewFile(uintptr(fd), "/dev/fuse") + conn, err := file.SyscallConn() + if err != nil { + file.Close() + return nil, err + } + _, readBufBytes, reqAllocBytes := requestAccountingSizes(ms.opts.MaxWrite) + return &fuseFD{ + server: ms, + file: file, + conn: conn, + reqPool: &ms.reqPool, + readPool: &ms.readPool, + buffers: &ms.buffers, + reqAllocBytes: reqAllocBytes, + readBufBytes: readBufBytes, + }, nil +} + +// withFD runs f with the underlying FUSE file descriptor, holding a +// reference on it for the duration so the fd cannot be closed (and its +// number reused) while f runs. This is what lets concurrent writers and +// close() run without a serializing mutex. The error is non-nil only +// when the fd is already closed, in which case f is not called. +func (r *fuseFD) withFD(f func(fd int)) error { + return r.conn.Control(func(fd uintptr) { + f(int(fd)) + }) +} + +// writevFD writes iov to the FUSE fd, holding a reference against +// concurrent close. The error is the underlying syscall error, or a +// non-syscall error if the fd is already closed. +func (r *fuseFD) writevFD(iov [][]byte) (int, error) { + var n int + var err error + if cerr := r.withFD(func(fd int) { + n, err = writev(fd, iov) + }); cerr != nil { + return 0, cerr + } + return n, err +} + +// readRequest reads one request from the kernel. Returns nil, OK if +// there are too many concurrent readers or insufficient request-bytes +// budget. +func (r *fuseFD) readRequest() (req *requestAlloc, code Status) { + ms := r.server + r.reqMu.Lock() + if r.reqReaders > ms.maxReaders || !r.reserveRequestBytes() { + r.reqMu.Unlock() + return nil, OK + } + r.reqReaders++ + r.reqMu.Unlock() + + req = r.reqPool.Get().(*requestAlloc) + dest := r.readPool.Get().([]byte) + + var n int + err := handleEINTR(func() error { + var err error + if cerr := r.withFD(func(fd int) { + n, err = syscall.Read(fd, dest) + }); cerr != nil { + return cerr + } + return err + }) + if err != nil { + r.reqMu.Lock() + r.putReadBuf(dest) + r.putReq(req) + r.reqReaders-- + r.reqMu.Unlock() + return nil, ToStatus(err) + } + + r.reqMu.Lock() + defer r.reqMu.Unlock() + gobbled := req.setInput(dest[:n]) + if len(req.inputBuf) < int(unsafe.Sizeof(InHeader{})) { + log.Printf("Short read for input header: %v", req.inputBuf) + r.putReadBuf(dest) + r.putReq(req) + r.reqReaders-- + return nil, EINVAL + } + opCode := ((*InHeader)(unsafe.Pointer(&req.inputBuf[0]))).Opcode + /* These messages don't expect reply, so they cost nothing for + the kernel to send. Make sure we're not overwhelmed by not + spawning a new reader. + */ + needsBackPressure := (opCode == _OP_FORGET || opCode == _OP_BATCH_FORGET) + + if !gobbled { + r.putReadBuf(dest) + } + r.reqReaders-- + if !ms.singleReader && r.reqReaders <= 0 && !needsBackPressure { + r.loops.Add(1) + go ms.loop() + } + + return req, OK +} + +// returnRequest returns a request to the pool of unused requests. +func (r *fuseFD) returnRequest(req *requestAlloc) { + if req.bufferPoolOutputBuf != nil { + r.buffers.FreeBuffer(req.bufferPoolOutputBuf) + req.bufferPoolOutputBuf = nil + } + if req.interrupted { + req.interrupted = false + req.cancel = make(chan struct{}, 0) + } + req.clear() + + r.reqMu.Lock() + if p := req.bufferPoolInputBuf; p != nil { + req.bufferPoolInputBuf = nil + r.putReadBuf(p) + } + r.putReq(req) + r.reqMu.Unlock() +} + +func (r *fuseFD) reserveRequestBytes() bool { + if !r.canReserveRequestBytes() { + return false + } + r.inflightRequestBytes += r.requestBytes() + return true +} + +func (r *fuseFD) canReserveRequestBytes() bool { + return r.inflightRequestBytes == 0 || + r.requestBytes() <= r.server.opts.MaxInflightRequestBytes-r.inflightRequestBytes +} + +// canAcceptAnother wraps canReserveRequestBytes with reqMu, for callers +// that don't already hold the lock. +func (r *fuseFD) canAcceptAnother() bool { + r.reqMu.Lock() + defer r.reqMu.Unlock() + return r.canReserveRequestBytes() +} + +func (r *fuseFD) requestBytes() int { + return r.reqAllocBytes + r.readBufBytes +} + +func (r *fuseFD) putReadBuf(buf []byte) { + r.readPool.Put(buf) + r.inflightRequestBytes -= r.readBufBytes +} + +func (r *fuseFD) putReq(req *requestAlloc) { + r.reqPool.Put(req) + r.inflightRequestBytes -= r.reqAllocBytes +} + +// close closes the underlying FUSE fd. The *os.File waits for in-flight +// RawConn operations (reads, writes, ioctls) before releasing the fd. +func (r *fuseFD) close() error { + return r.file.Close() +} + +func (r *fuseFD) writev(iov [][]byte) (int, syscall.Errno) { + n, err := r.writevFD(iov) + if err == nil { + return n, 0 + } + errno, ok := err.(syscall.Errno) + if !ok { + // The fd is closed; report it as such. + return n, syscall.EBADF + } + if errno == syscall.EINVAL { + // Detail: the kernel returns EINVAL for unsupported + // notify methods. + errno = syscall.ENOSYS + } + return n, errno +} + +func (r *fuseFD) writeWithSlice(req *request, ws withSlice) Status { + slices, st := ws.Slices() + if !st.Ok() { + return st + } + bufs := make([][]byte, 0, 2+len(slices)) + bufs = append(bufs, req.outHeaderBuf, req.outDataBuf) + bufs = append(bufs, slices...) + _, err := r.writevFD(bufs) + return ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/fusefd_linux.go b/third_party/go-fuse/fuse/fusefd_linux.go new file mode 100644 index 000000000..a7004ebb8 --- /dev/null +++ b/third_party/go-fuse/fuse/fusefd_linux.go @@ -0,0 +1,43 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +const useSingleReader = false + +func (r *fuseFD) write(req *request) Status { + if req.outPayloadSize() == 0 { + err := handleEINTR(func() error { + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf}) + return err + }) + return ToStatus(err) + } + + if req.readResult != nil { + defer func() { + req.readResult.Done() + req.readResult = nil + }() + if ws, ok := req.readResult.(withSlice); ok { + return r.writeWithSlice(req, ws) + } + + if r.server.canSplice && !r.server.opts.DisableSplice { + err := r.trySplice(req, req.readResult) + if err == nil { + return OK + } + if err != errRecoverSplice { + r.server.opts.Logger.Println("trySplice:", err) + } + } + + req.outPayload, req.status = req.readResult.Bytes(req.outPayload) + req.serializeHeader(len(req.outPayload)) + } + + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf, req.outPayload}) + return ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/fusefd_other.go b/third_party/go-fuse/fuse/fusefd_other.go new file mode 100644 index 000000000..c4f90a15f --- /dev/null +++ b/third_party/go-fuse/fuse/fusefd_other.go @@ -0,0 +1,33 @@ +//go:build !linux + +package fuse + +// OSX and FreeBSD has races when multiple routines read +// from the FUSE device: on unmount, sometime some reads +// do not error-out, meaning that unmount will hang. +const useSingleReader = true + +func (r *fuseFD) write(req *request) Status { + if req.outPayloadSize() == 0 { + err := handleEINTR(func() error { + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf}) + return err + }) + return ToStatus(err) + } + if req.readResult != nil { + defer func() { + req.readResult.Done() + req.readResult = nil + }() + if ws, ok := req.readResult.(withSlice); ok { + return r.writeWithSlice(req, ws) + } + + req.outPayload, req.status = req.readResult.Bytes(req.outPayload) + req.serializeHeader(len(req.outPayload)) + } + + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf, req.outPayload}) + return ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/misc.go b/third_party/go-fuse/fuse/misc.go new file mode 100644 index 000000000..8b7b63805 --- /dev/null +++ b/third_party/go-fuse/fuse/misc.go @@ -0,0 +1,96 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Random odds and ends. + +package fuse + +import ( + "errors" + "fmt" + "log" + "os" + "syscall" + "time" +) + +var notifyNames = []string{ + "OK", + "NOTIFY_POLL", + "NOTIFY_INVAL_INODE", + "NOTIFY_INVAL_ENTRY", + "NOTIFY_STORE_CACHE", + "NOTIFY_RETRIEVE_CACHE", + "NOTIFY_DELETE", + "NOTIFY_RESEND", + "NOTIFY_INC_EPOCH", + "NOTIFY_PRUNE", +} + +func (code Status) String() string { + if code <= 0 { + idx := int(-code) + if idx < len(notifyNames) { + return notifyNames[idx] + } + + return fmt.Sprintf("negative(%d)", code) + } + return fmt.Sprintf("%d=%v", int(code), syscall.Errno(code)) +} + +func (code Status) Ok() bool { + return code == OK +} + +// ToStatus extracts an errno number from Go error objects. If it +// fails, it logs an error and returns ENOSYS. +func ToStatus(err error) Status { + if err == nil { + return OK + } + + switch err { + case os.ErrPermission: + return EPERM + case os.ErrExist: + return Status(syscall.EEXIST) + case os.ErrNotExist: + return ENOENT + case os.ErrInvalid: + return EINVAL + } + + var errno syscall.Errno + if errors.As(err, &errno) { + return Status(errno) + } + log.Println("can't convert error type:", err) + return ENOTSUP +} + +func CurrentOwner() *Owner { + return &Owner{ + Uid: uint32(os.Getuid()), + Gid: uint32(os.Getgid()), + } +} + +// UtimeToTimespec converts a "Time" pointer as passed to Utimens to a +// "Timespec" that can be passed to the utimensat syscall. +// A nil pointer is converted to the special UTIME_OMIT value. +// +// Deprecated: use unix.TimeToTimespec from the x/sys/unix package instead. +func UtimeToTimespec(t *time.Time) (ts syscall.Timespec) { + if t == nil { + ts.Nsec = _UTIME_OMIT + } else { + ts = syscall.NsecToTimespec(t.UnixNano()) + // Go bug https://github.com/golang/go/issues/12777 + if ts.Nsec < 0 { + ts.Nsec = 0 + } + } + return ts +} diff --git a/third_party/go-fuse/fuse/misc_darwin.go b/third_party/go-fuse/fuse/misc_darwin.go new file mode 100644 index 000000000..9e60b809a --- /dev/null +++ b/third_party/go-fuse/fuse/misc_darwin.go @@ -0,0 +1,4 @@ +package fuse + +// Ref: https://github.com/apple/darwin-xnu/blob/main/bsd/sys/stat.h#L576 +const _UTIME_OMIT = -2 diff --git a/third_party/go-fuse/fuse/misc_unix.go b/third_party/go-fuse/fuse/misc_unix.go new file mode 100644 index 000000000..55f832adc --- /dev/null +++ b/third_party/go-fuse/fuse/misc_unix.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package fuse + +import "golang.org/x/sys/unix" + +const _UTIME_OMIT = unix.UTIME_OMIT diff --git a/third_party/go-fuse/fuse/mount.go b/third_party/go-fuse/fuse/mount.go new file mode 100644 index 000000000..43399399b --- /dev/null +++ b/third_party/go-fuse/fuse/mount.go @@ -0,0 +1,72 @@ +package fuse + +import ( + "fmt" + "net" + "os" + "syscall" +) + +var reservedFDs []*os.File + +func init() { + // Both Darwin and Linux invoke a subprocess with one + // inherited file descriptor to create the mount. To protect + // against deadlock, we must ensure that file descriptor 3 + // never points to a FUSE filesystem. We do this by simply + // grabbing fd 3 and never releasing it. (This is not + // completely foolproof: a preceding init routine could grab fd 3, + // and then release it later.) + for { + r, w, err := os.Pipe() + if err != nil { + panic(fmt.Sprintf("os.Pipe(): %v", err)) + } + w.Close() + if r.Fd() > 3 { + r.Close() + break + } + reservedFDs = append(reservedFDs, r) + } +} + +func getConnection(local *os.File) (int, error) { + conn, err := net.FileConn(local) + if err != nil { + return 0, err + } + defer conn.Close() + unixConn := conn.(*net.UnixConn) + + var data [4]byte + control := make([]byte, 4*256) + + _, oobn, _, _, err := unixConn.ReadMsgUnix(data[:], control[:]) + if err != nil { + return 0, err + } + + messages, err := syscall.ParseSocketControlMessage(control[:oobn]) + if err != nil { + return 0, err + } + if len(messages) != 1 { + return 0, fmt.Errorf("getConnection: expect 1 control message, got %#v", messages) + } + message := messages[0] + + fds, err := syscall.ParseUnixRights(&message) + if err != nil { + return 0, err + } + if len(fds) != 1 { + return 0, fmt.Errorf("getConnection: expect 1 fd, got %#v", fds) + } + fd := fds[0] + + if fd < 0 { + return 0, fmt.Errorf("getConnection: fd < 0: %d", fd) + } + return fd, nil +} diff --git a/third_party/go-fuse/fuse/mount_darwin.go b/third_party/go-fuse/fuse/mount_darwin.go new file mode 100644 index 000000000..1e733aede --- /dev/null +++ b/third_party/go-fuse/fuse/mount_darwin.go @@ -0,0 +1,114 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +func getMaxWrite() int { + return 1 << 20 +} + +func unixgramSocketpair() (l, r *os.File, err error) { + fd, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return nil, nil, os.NewSyscallError("socketpair", + err.(syscall.Errno)) + } + l = os.NewFile(uintptr(fd[0]), "socketpair-half1") + r = os.NewFile(uintptr(fd[1]), "socketpair-half2") + return +} + +// Create a FUSE FS on the specified mount point. The returned +// mount point is always absolute. +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + local, remote, err := unixgramSocketpair() + if err != nil { + return + } + + defer local.Close() + defer remote.Close() + + bin, err := fusermountBinary() + if err != nil { + return 0, err + } + + cmd := exec.Command(bin, + "-o", strings.Join(opts.optionsStrings(), ","), + "-o", fmt.Sprintf("iosize=%d", opts.MaxWrite), + mountPoint) + cmd.ExtraFiles = []*os.File{remote} // fd would be (index + 3) + cmd.Env = append(os.Environ(), + "_FUSE_CALL_BY_LIB=", + "_FUSE_DAEMON_PATH="+os.Args[0], + "_FUSE_COMMFD=3", + "_FUSE_COMMVERS=2", + "MOUNT_OSXFUSE_CALL_BY_LIB=", + "MOUNT_OSXFUSE_DAEMON_PATH="+os.Args[0]) + + var out, errOut bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errOut + + if err = cmd.Start(); err != nil { + return + } + + fd, err = getConnection(local) + if err != nil { + return -1, err + } + + go func() { + // On macos, mount_osxfuse is not just a suid + // wrapper. It interacts with the FS setup, and will + // not exit until the filesystem has successfully + // responded to INIT and STATFS. This means we can + // only wait on the mount_osxfuse process after the + // server has fully started + if err := cmd.Wait(); err != nil { + err = fmt.Errorf("mount_osxfusefs failed: %v. Stderr: %s, Stdout: %s", + err, errOut.String(), out.String()) + } + + ready <- err + close(ready) + }() + + // golang sets CLOEXEC on file descriptors when they are + // acquired through normal operations (e.g. open). + // Buf for fd, we have to set CLOEXEC manually + syscall.CloseOnExec(fd) + + return fd, err +} + +func unmount(dir string, opts *MountOptions) error { + return syscall.Unmount(dir, 0) +} + +func fusermountBinary() (string, error) { + binPaths := []string{ + "/Library/Filesystems/macfuse.fs/Contents/Resources/mount_macfuse", + "/Library/Filesystems/osxfuse.fs/Contents/Resources/mount_osxfuse", + } + + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("no FUSE mount utility found") +} diff --git a/third_party/go-fuse/fuse/mount_freebsd.go b/third_party/go-fuse/fuse/mount_freebsd.go new file mode 100644 index 000000000..694287eca --- /dev/null +++ b/third_party/go-fuse/fuse/mount_freebsd.go @@ -0,0 +1,146 @@ +package fuse + +import ( + "fmt" + "os" + "strings" + "syscall" +) + +func getMaxWrite() int { + return 1 << 20 +} + +func callMountFuseFs(mountPoint string, opts *MountOptions) (devFuseFd int, err error) { + bin, err := fusermountBinary() + if err != nil { + return -1, err + } + + // Convenience for cleaning up open FDs in the event of an error. + closeFd := func(fd int) { + if err != nil { + syscall.Close(fd) + } + } + + // Use syscall.Open instead of os.OpenFile to avoid Go garbage collecting an + // [os.File], which will close the FD later, but we need to keep it open for + // the life of the FUSE mount. + const devFuse = "/dev/fuse" + devFuseFd, err = syscall.Open(devFuse, syscall.O_RDWR, 0) + if err != nil { + return -1, fmt.Errorf( + "failed to get a RDWR file descriptor for %s: %w", + devFuse, err) + } + defer closeFd(devFuseFd) + // We will also defensively direct the forked process's stdin to the null + // device to prevent any unintended side effects. + devNullFd, err := syscall.Open(os.DevNull, syscall.O_RDONLY, 0) + if err != nil { + return -1, fmt.Errorf( + "failed to get a RDONLY file descriptor for %s: %w", + os.DevNull, err) + } + defer closeFd(devNullFd) + + // Use syscall.ForkExec directly instead of exec.Command because we need to + // pass raw file descriptors to the child, rather than a garbage collected + // os.File. + env := []string{"MOUNT_FUSEFS_CALL_BY_LIB=1"} + argv := []string{ + bin, + "--safe", + "-o", strings.Join(opts.optionsStrings(), ","), + "3", + mountPoint, + } + fds := []uintptr{ + uintptr(devNullFd), + uintptr(os.Stdout.Fd()), + uintptr(os.Stderr.Fd()), + uintptr(devFuseFd), + } + pid, err := syscall.ForkExec(bin, argv, &syscall.ProcAttr{ + Env: env, + Files: fds, + }) + if err != nil { + return -1, fmt.Errorf("failed to fork mount_fusefs: %w", err) + } + + var ws syscall.WaitStatus + _, err = syscall.Wait4(pid, &ws, 0, nil) + if err != nil { + return -1, fmt.Errorf( + "failed to wait for exit status of mount_fusefs: %w", err) + } + if ws.ExitStatus() != 0 { + return -1, fmt.Errorf( + "mount_fusefs: exited with status %d", ws.ExitStatus()) + } + + return devFuseFd, nil +} + +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + // Note: opts.DirectMount is not supported in FreeBSD, but the intended + // behavior is to *attempt* a direct mount when it's set, not to return an + // error. So in this case, we just ignore it and use the binary from + // fusermountBinary(). + + // Using the same logic from libfuse to prevent chaos + for { + f, err := os.OpenFile("/dev/null", os.O_RDWR, 0o000) + if err != nil { + return -1, err + } + if f.Fd() > 2 { + f.Close() + break + } + } + + // Magic `/dev/fd/N` mountpoint. See the docs for NewServer() for how this + // works. + + fd = parseFuseFd(mountPoint) + if fd >= 0 { + if opts.Debug { + opts.Logger.Printf("mount: magic mountpoint %q, using fd %d", mountPoint, fd) + } + } else { + // Usual case: mount via the `fusermount` suid helper + fd, err = callMountFuseFs(mountPoint, opts) + if err != nil { + return + } + } + // golang sets CLOEXEC on file descriptors when they are + // acquired through normal operations (e.g. open). + // However, for raw FDs, we have to set CLOEXEC manually. + syscall.CloseOnExec(fd) + + close(ready) + return fd, err +} + +func unmount(mountPoint string, opts *MountOptions) (err error) { + _ = opts + return syscall.Unmount(mountPoint, 0) +} + +func fusermountBinary() (string, error) { + binPaths := []string{ + "/sbin/mount_fusefs", + } + + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("no FUSE mount utility found") +} diff --git a/third_party/go-fuse/fuse/mount_linux.go b/third_party/go-fuse/fuse/mount_linux.go new file mode 100644 index 000000000..7fddae27e --- /dev/null +++ b/third_party/go-fuse/fuse/mount_linux.go @@ -0,0 +1,279 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path" + "strconv" + "strings" + "syscall" +) + +func unixgramSocketpair() (l, r *os.File, err error) { + fd, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + if err != nil { + return nil, nil, os.NewSyscallError("socketpair", + err.(syscall.Errno)) + } + l = os.NewFile(uintptr(fd[0]), "socketpair-half1") + r = os.NewFile(uintptr(fd[1]), "socketpair-half2") + return +} + +// Create a FUSE FS on the specified mount point without using +// fusermount. +func mountDirect(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + // managed to open dev/fuse, attempt to mount + source := opts.FsName + if source == "" { + source = opts.Name + } + + var flags uintptr = syscall.MS_NOSUID | syscall.MS_NODEV + if opts.DirectMountFlags != 0 { + flags = opts.DirectMountFlags + } + + var st syscall.Stat_t + err = syscall.Stat(mountPoint, &st) + if err != nil { + return + } + + fd, err = syscall.Open("/dev/fuse", os.O_RDWR, 0) // use syscall.Open since we want an int fd + if err != nil { + return + } + + // some values we need to pass to mount - we do as fusermount does. + // override possible since opts.Options comes after. + // + // Only the options listed below are understood by the kernel: + // https://elixir.bootlin.com/linux/v6.14.2/source/fs/fuse/inode.c#L772 + // https://elixir.bootlin.com/linux/v6.14.2/source/fs/fs_context.c#L41 + // https://elixir.bootlin.com/linux/v6.14.2/source/fs/fs_context.c#L50 + // Everything else will cause an EINVAL error from syscall.Mount() and + // a corresponding error message in the kernel logs. + var r = []string{ + fmt.Sprintf("fd=%d", fd), + fmt.Sprintf("rootmode=%o", st.Mode&syscall.S_IFMT), + fmt.Sprintf("user_id=%d", os.Geteuid()), + fmt.Sprintf("group_id=%d", os.Getegid()), + // match what we do with fusermount + fmt.Sprintf("max_read=%d", opts.MaxWrite), + } + + // In syscall.Mount(), [no]dev/suid/exec must be + // passed as bits in the flags parameter, not as strings. + for _, o := range opts.Options { + switch o { + case "nodev": + flags |= syscall.MS_NODEV + case "dev": + flags &^= syscall.MS_NODEV + case "nosuid": + flags |= syscall.MS_NOSUID + case "suid": + flags &^= syscall.MS_NOSUID + case "noexec": + flags |= syscall.MS_NOEXEC + case "exec": + flags &^= syscall.MS_NOEXEC + default: + r = append(r, o) + } + } + + if opts.AllowOther { + r = append(r, "allow_other") + } + if opts.IDMappedMount && !opts.containsOption("default_permissions") { + r = append(r, "default_permissions") + } + + if opts.Debug { + opts.Logger.Printf("mountDirect: calling syscall.Mount(%q, %q, %q, %#x, %q)", + source, mountPoint, "fuse."+opts.Name, flags, strings.Join(r, ",")) + } + err = syscall.Mount(source, mountPoint, "fuse."+opts.Name, flags, strings.Join(r, ",")) + if err != nil { + syscall.Close(fd) + return + } + + // success + close(ready) + return +} + +// callFusermount calls the `fusermount` suid helper with the right options so +// that it: +// * opens `/dev/fuse` +// * mount()s this file descriptor to `mountPoint` +// * passes this file descriptor back to us via a unix domain socket +// This file descriptor is returned as `fd`. +func callFusermount(mountPoint string, opts *MountOptions) (fd int, err error) { + local, remote, err := unixgramSocketpair() + if err != nil { + return + } + + defer local.Close() + defer remote.Close() + + bin, err := fusermountBinary() + if err != nil { + return 0, err + } + + cmd := []string{bin, mountPoint} + if s := opts.optionsStrings(); len(s) > 0 { + cmd = append(cmd, "-o", strings.Join(s, ",")) + } + if opts.Debug { + opts.Logger.Printf("callFusermount: executing %q", cmd) + } + proc, err := os.StartProcess(bin, + cmd, + &os.ProcAttr{ + Env: []string{"_FUSE_COMMFD=3"}, + Files: []*os.File{os.Stdin, os.Stdout, os.Stderr, remote}}) + + if err != nil { + return + } + + w, err := proc.Wait() + if err != nil { + return + } + if !w.Success() { + err = fmt.Errorf("fusermount exited with code %v\n", w.Sys()) + return + } + + fd, err = getConnection(local) + if err != nil { + return -1, err + } + + return +} + +// Create a FUSE FS on the specified mount point. The returned +// mount point is always absolute. +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + if opts.DirectMount || opts.DirectMountStrict { + fd, err := mountDirect(mountPoint, opts, ready) + if err == nil { + return fd, nil + } else if opts.Debug { + opts.Logger.Printf("mount: failed to do direct mount: %s", err) + } + if opts.DirectMountStrict { + return -1, err + } + } + + // Magic `/dev/fd/N` mountpoint. See the docs for NewServer() for how this + // works. + fd = parseFuseFd(mountPoint) + if fd >= 0 { + if opts.Debug { + opts.Logger.Printf("mount: magic mountpoint %q, using fd %d", mountPoint, fd) + } + } else { + // Usual case: mount via the `fusermount` suid helper + fd, err = callFusermount(mountPoint, opts) + if err != nil { + return + } + } + // golang sets CLOEXEC on file descriptors when they are + // acquired through normal operations (e.g. open). + // Buf for fd, we have to set CLOEXEC manually + syscall.CloseOnExec(fd) + close(ready) + return fd, err +} + +func unmount(mountPoint string, opts *MountOptions) (err error) { + if opts.DirectMount || opts.DirectMountStrict { + // Attempt to directly unmount, if fails fallback to fusermount method + err := syscall.Unmount(mountPoint, 0) + if err == nil { + return nil + } + if opts.DirectMountStrict { + return err + } + } + + bin, err := fusermountBinary() + if err != nil { + return err + } + errBuf := bytes.Buffer{} + cmd := exec.Command(bin, "-u", mountPoint) + cmd.Stderr = &errBuf + if opts.Debug { + opts.Logger.Printf("unmount: executing %q", cmd.Args) + } + err = cmd.Run() + if errBuf.Len() > 0 { + return fmt.Errorf("%s (code %v)\n", + errBuf.String(), err) + } + return err +} + +// lookPathFallback - search binary in PATH and, if that fails, +// in fallbackDir. This is useful if PATH is possible empty. +func lookPathFallback(file string, fallbackDir string) (string, error) { + binPath, err := exec.LookPath(file) + if err == nil { + return binPath, nil + } + + abs := path.Join(fallbackDir, file) + return exec.LookPath(abs) +} + +// fusermountBinary returns the path to the `fusermount3` binary, or, if not +// found, the `fusermount` binary. +func fusermountBinary() (string, error) { + if path, err := lookPathFallback("fusermount3", "/bin"); err == nil { + return path, nil + } + return lookPathFallback("fusermount", "/bin") +} + +func umountBinary() (string, error) { + return lookPathFallback("umount", "/bin") +} + +func getMaxWrite() int { + return maxPageLimit() * syscall.Getpagesize() +} + +func maxPageLimit() (lim int) { + lim = 256 + + d, err := os.ReadFile("/proc/sys/fs/fuse/max_pages_limit") + if err != nil { + return lim + } + + newLim, err := strconv.Atoi(strings.TrimSpace(string(d))) + if err != nil { + return lim + } + + return newLim +} diff --git a/third_party/go-fuse/fuse/opcode.go b/third_party/go-fuse/fuse/opcode.go new file mode 100644 index 000000000..c6014c89a --- /dev/null +++ b/third_party/go-fuse/fuse/opcode.go @@ -0,0 +1,806 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "log" + "runtime" + "syscall" + "unsafe" +) + +const ( + _OP_LOOKUP = uint32(1) + _OP_FORGET = uint32(2) + _OP_GETATTR = uint32(3) + _OP_SETATTR = uint32(4) + _OP_READLINK = uint32(5) + _OP_SYMLINK = uint32(6) + _OP_MKNOD = uint32(8) + _OP_MKDIR = uint32(9) + _OP_UNLINK = uint32(10) + _OP_RMDIR = uint32(11) + _OP_RENAME = uint32(12) + _OP_LINK = uint32(13) + _OP_OPEN = uint32(14) + _OP_READ = uint32(15) + _OP_WRITE = uint32(16) + _OP_STATFS = uint32(17) + _OP_RELEASE = uint32(18) + _OP_FSYNC = uint32(20) + _OP_SETXATTR = uint32(21) + _OP_GETXATTR = uint32(22) + _OP_LISTXATTR = uint32(23) + _OP_REMOVEXATTR = uint32(24) + _OP_FLUSH = uint32(25) + _OP_INIT = uint32(26) + _OP_OPENDIR = uint32(27) + _OP_READDIR = uint32(28) + _OP_RELEASEDIR = uint32(29) + _OP_FSYNCDIR = uint32(30) + _OP_GETLK = uint32(31) + _OP_SETLK = uint32(32) + _OP_SETLKW = uint32(33) + _OP_ACCESS = uint32(34) + _OP_CREATE = uint32(35) + _OP_INTERRUPT = uint32(36) + _OP_BMAP = uint32(37) + _OP_DESTROY = uint32(38) + _OP_IOCTL = uint32(39) + _OP_POLL = uint32(40) + _OP_NOTIFY_REPLY = uint32(41) + _OP_BATCH_FORGET = uint32(42) + _OP_FALLOCATE = uint32(43) // protocol version 19. + _OP_READDIRPLUS = uint32(44) // protocol version 21. + _OP_RENAME2 = uint32(45) // protocol version 23. + _OP_LSEEK = uint32(46) // protocol version 24 + _OP_COPY_FILE_RANGE = uint32(47) // protocol version 28. + + _OP_SETUPMAPPING = 48 + _OP_REMOVEMAPPING = 49 + _OP_SYNCFS = 50 + _OP_TMPFILE = 51 + _OP_STATX = 52 + _OP_COPY_FILE_RANGE_64 = 53 + + // The following entries don't have to be compatible across Go-FUSE versions. + _OP_NOTIFY_INVAL_ENTRY = uint32(100) + _OP_NOTIFY_INVAL_INODE = uint32(101) + _OP_NOTIFY_STORE_CACHE = uint32(102) + _OP_NOTIFY_RETRIEVE_CACHE = uint32(103) + _OP_NOTIFY_DELETE = uint32(104) // protocol version 18 + _OP_NOTIFY_PRUNE = uint32(105) // protocol version 45 + + _OPCODE_COUNT = uint32(106) + + // Constants from Linux kernel fs/fuse/fuse_i.h + // Default MaxPages value in all kernel versions + _FUSE_DEFAULT_MAX_PAGES_PER_REQ = 32 + // Upper MaxPages limit in Linux v4.20+ (v4.19 and older: 32) + _FUSE_MAX_MAX_PAGES = 256 +) + +//////////////////////////////////////////////////////////////// + +func doInit(server *protocolServer, req *request) { + input := (*InitIn)(req.inData()) + if input.Major != _FUSE_KERNEL_VERSION { + log.Printf("Major versions does not match. Given %d, want %d\n", input.Major, _FUSE_KERNEL_VERSION) + req.status = EIO + return + } + if input.Minor < _MINIMUM_MINOR_VERSION { + log.Printf("Minor version is less than we support. Given %d, want at least %d\n", input.Minor, _MINIMUM_MINOR_VERSION) + req.status = EIO + return + } + + kernelFlags := input.Flags64() + server.kernelSettings = *input + kernelFlags &= (CAP_ASYNC_READ | CAP_BIG_WRITES | CAP_FILE_OPS | + CAP_READDIRPLUS | CAP_NO_OPEN_SUPPORT | CAP_PARALLEL_DIROPS | CAP_MAX_PAGES | CAP_RENAME_SWAP | CAP_PASSTHROUGH | CAP_ALLOW_IDMAP | + server.opts.ExtraCapabilities) + + if server.opts.EnableLocks { + kernelFlags |= input.Flags64() & (CAP_FLOCK_LOCKS | CAP_POSIX_LOCKS) + } + if server.opts.EnableSymlinkCaching { + kernelFlags |= input.Flags64() & CAP_CACHE_SYMLINKS + } + if server.opts.EnableAcl { + kernelFlags |= input.Flags64() & CAP_POSIX_ACL + } + + if server.opts.ExplicitDataCacheControl { + // we don't want CAP_AUTO_INVAL_DATA even if we cannot go into fully explicit mode + kernelFlags |= input.Flags64() & CAP_EXPLICIT_INVAL_DATA + } else { + kernelFlags |= input.Flags64() & CAP_AUTO_INVAL_DATA + } + + kernelFlags = kernelFlags &^ server.opts.DisabledCapabilities + + // maxPages is the maximum request size we want the kernel to use, in units of + // memory pages (usually 4kiB). Linux v4.19 and older ignore this and always use + // 128kiB. + maxPages := (server.opts.MaxWrite-1)/syscall.Getpagesize() + 1 // Round up + + congestionThreshold := server.opts.CongestionThreshold + if congestionThreshold <= 0 { + congestionThreshold = server.opts.MaxBackground * 3 / 4 + } + + out := (*InitOut)(req.outData()) + *out = InitOut{ + Major: _FUSE_KERNEL_VERSION, + Minor: _OUR_MINOR_VERSION, + MaxReadAhead: input.MaxReadAhead, + MaxWrite: uint32(server.opts.MaxWrite), + CongestionThreshold: uint16(congestionThreshold), + MaxBackground: uint16(server.opts.MaxBackground), + MaxPages: uint16(maxPages), + MaxStackDepth: uint32(server.opts.MaxStackDepth), + } + out.setFlags(kernelFlags) + server.negotiatedFlags = out.Flags64() + if server.opts.MaxReadAhead != 0 && uint32(server.opts.MaxReadAhead) < out.MaxReadAhead { + out.MaxReadAhead = uint32(server.opts.MaxReadAhead) + } + if out.Minor > input.Minor { + out.Minor = input.Minor + } + + req.status = OK +} + +func doOpen(server *protocolServer, req *request) { + out := (*OpenOut)(req.outData()) + status := server.fileSystem.Open(req.cancel, (*OpenIn)(req.inData()), out) + req.status = status + if status != OK { + return + } +} + +func doCreate(server *protocolServer, req *request) { + out := (*CreateOut)(req.outData()) + status := server.fileSystem.Create(req.cancel, (*CreateIn)(req.inData()), req.filename(), out) + req.status = status +} + +// doTmpfile answers a request for a file with no name. The kernel sends the name of the anonymous dentry it made +// for it, which is "/" and means nothing, so it is not passed on. +func doTmpfile(server *protocolServer, req *request) { + out := (*CreateOut)(req.outData()) + status := server.fileSystem.Tmpfile(req.cancel, (*CreateIn)(req.inData()), out) + req.status = status +} + +func doReadDir(server *protocolServer, req *request) { + in := (*ReadIn)(req.inData()) + out := NewDirEntryList(req.outPayload, uint64(in.Offset)) + code := server.fileSystem.ReadDir(req.cancel, in, out) + req.outPayload = out.Bytes() + req.status = code +} + +func doReadDirPlus(server *protocolServer, req *request) { + in := (*ReadIn)(req.inData()) + out := NewDirEntryList(req.outPayload, uint64(in.Offset)) + + code := server.fileSystem.ReadDirPlus(req.cancel, in, out) + req.outPayload = out.Bytes() + req.status = code +} + +func doOpenDir(server *protocolServer, req *request) { + out := (*OpenOut)(req.outData()) + status := server.fileSystem.OpenDir(req.cancel, (*OpenIn)(req.inData()), out) + req.status = status +} + +func doSetattr(server *protocolServer, req *request) { + out := (*AttrOut)(req.outData()) + req.status = server.fileSystem.SetAttr(req.cancel, (*SetAttrIn)(req.inData()), out) +} + +func doWrite(server *protocolServer, req *request) { + in := (*WriteIn)(req.inData()) + o := (*WriteOut)(req.outData()) + data := req.inPayloadIov + if data == nil { + req.inPayloadOne[0] = req.inPayload + data = req.inPayloadOne[:] + } + o.Size, req.status = server.fileSystem.Writev(req.cancel, in, data) + if req.status != ENOSYS { + return + } + if len(data) == 1 { + o.Size, req.status = server.fileSystem.Write(req.cancel, in, data[0]) + return + } + server.writevCopyOnce.Do(func() { + server.opts.Logger.Printf("Writev not implemented; copying split WRITE data") + }) + buf := server.buffers.AllocBuffer(uint32(iovLen(data))) + defer server.buffers.FreeBuffer(buf) + copyFromIov(buf, data, 0) + o.Size, req.status = server.fileSystem.Write(req.cancel, in, buf) +} + +func doNotifyReply(server *protocolServer, req *request) { + reply := (*NotifyRetrieveIn)(req.inData()) + server.retrieveMu.Lock() + reading := server.retrieveTab[reply.Unique] + delete(server.retrieveTab, reply.Unique) + server.retrieveMu.Unlock() + + badf := func(format string, argv ...any) { + server.opts.Logger.Printf("notify reply: "+format, argv...) + } + + if reading == nil { + badf("unexpected unique - ignoring") + return + } + + reading.n = 0 + reading.st = EIO + defer close(reading.ready) + + if reading.nodeid != reply.NodeId { + badf("inode mismatch: expected %s, got %s", reading.nodeid, reply.NodeId) + return + } + + if reading.offset != reply.Offset { + badf("offset mismatch: expected @%d, got @%d", reading.offset, reply.Offset) + return + } + + if len(reading.dest) < len(req.inPayload) { + badf("too much data: requested %db, got %db (will use only %db)", len(reading.dest), len(req.inPayload), len(reading.dest)) + } + + reading.n = copy(reading.dest, req.inPayload) + reading.st = OK +} + +const _SECURITY_CAPABILITY = "security.capability" +const _SECURITY_ACL = "system.posix_acl_access" +const _SECURITY_ACL_DEFAULT = "system.posix_acl_default" + +func doGetXAttr(server *protocolServer, req *request) { + if server.opts.DisableXAttrs { + req.status = ENOSYS + return + } + + if server.opts.IgnoreSecurityLabels && req.inHeader().Opcode == _OP_GETXATTR { + fn := req.filename() + if fn == _SECURITY_CAPABILITY || fn == _SECURITY_ACL_DEFAULT || + fn == _SECURITY_ACL { + req.status = ENOATTR + return + } + } + + input := (*GetXAttrIn)(req.inData()) + var n uint32 + switch req.inHeader().Opcode { + case _OP_GETXATTR: + n, req.status = server.fileSystem.GetXAttr(req.cancel, req.inHeader(), req.filename(), req.outPayload) + case _OP_LISTXATTR: + n, req.status = server.fileSystem.ListXAttr(req.cancel, req.inHeader(), req.outPayload) + default: + req.status = ENOSYS + } + + if input.Size == 0 { + if req.status == ERANGE { + // For input.size==0, returning ERANGE is an error. + req.status = OK + } + + out := (*GetXAttrOut)(req.outData()) + out.Size = n + } else if req.status.Ok() { + // ListXAttr called with an empty buffer returns the current size of + // the list but does not touch the buffer (see man 2 listxattr). + if len(req.outPayload) > 0 { + req.outPayload = req.outPayload[:n] + } + } else { + req.outPayload = req.outPayload[:0] + } +} + +func doGetAttr(server *protocolServer, req *request) { + out := (*AttrOut)(req.outData()) + s := server.fileSystem.GetAttr(req.cancel, (*GetAttrIn)(req.inData()), out) + req.status = s +} + +// doForget - forget one NodeId +func doForget(server *protocolServer, req *request) { + if !server.opts.RememberInodes { + server.fileSystem.Forget(req.inHeader().NodeId, (*ForgetIn)(req.inData()).Nlookup) + } +} + +// doBatchForget - forget a list of NodeIds +func doBatchForget(server *protocolServer, req *request) { + in := (*_BatchForgetIn)(req.inData()) + gotCount := len(req.inPayload) / int(unsafe.Sizeof(_ForgetOne{})) + if int(in.Count) > gotCount { + // We have no return value to complain, so log an error. + server.opts.Logger.Printf("Too few bytes for batch forget. Got %d bytes enough for %d entries (want %d entries)", + len(req.inPayload), gotCount, in.Count) + } + in.Count = uint32(gotCount) + if in.Count == 0 { + return + } + + forgets := unsafe.Slice((*_ForgetOne)(unsafe.Pointer(&req.inPayload[0])), in.Count) + for i, f := range forgets { + if server.opts.Debug { + server.opts.Logger.Printf("doBatchForget: rx %d %d/%d: FORGET n%d {Nlookup=%d}", + req.inHeader().Unique, i+1, len(forgets), f.NodeId, f.Nlookup) + } + if f.NodeId == pollHackInode { + continue + } + server.fileSystem.Forget(f.NodeId, f.Nlookup) + } +} + +func doReadlink(server *protocolServer, req *request) { + req.outPayload, req.status = server.fileSystem.Readlink(req.cancel, req.inHeader()) +} + +func doLookup(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + req.status = server.fileSystem.Lookup(req.cancel, req.inHeader(), req.filename(), out) +} + +func doMknod(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + + req.status = server.fileSystem.Mknod(req.cancel, (*MknodIn)(req.inData()), req.filename(), out) +} + +func doMkdir(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + req.status = server.fileSystem.Mkdir(req.cancel, (*MkdirIn)(req.inData()), req.filename(), out) +} + +func doUnlink(server *protocolServer, req *request) { + req.status = server.fileSystem.Unlink(req.cancel, req.inHeader(), req.filename()) +} + +func doRmdir(server *protocolServer, req *request) { + req.status = server.fileSystem.Rmdir(req.cancel, req.inHeader(), req.filename()) +} + +func doLink(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + req.status = server.fileSystem.Link(req.cancel, (*LinkIn)(req.inData()), req.filename(), out) +} + +func doRead(server *protocolServer, req *request) { + in := (*ReadIn)(req.inData()) + req.readResult, req.status = server.fileSystem.Read(req.cancel, in, req.outPayload) +} + +func doFlush(server *protocolServer, req *request) { + req.status = server.fileSystem.Flush(req.cancel, (*FlushIn)(req.inData())) +} + +func doRelease(server *protocolServer, req *request) { + server.fileSystem.Release(req.cancel, (*ReleaseIn)(req.inData())) +} + +func doFsync(server *protocolServer, req *request) { + req.status = server.fileSystem.Fsync(req.cancel, (*FsyncIn)(req.inData())) +} + +func doReleaseDir(server *protocolServer, req *request) { + server.fileSystem.ReleaseDir((*ReleaseIn)(req.inData())) +} + +func doFsyncDir(server *protocolServer, req *request) { + req.status = server.fileSystem.FsyncDir(req.cancel, (*FsyncIn)(req.inData())) +} + +func doSetXAttr(server *protocolServer, req *request) { + if server.opts.DisableXAttrs { + req.status = ENOSYS + return + } + i := bytes.IndexByte(req.inPayload, 0) + if i < 0 { + req.status = EINVAL + return + } + req.status = server.fileSystem.SetXAttr(req.cancel, (*SetXAttrIn)(req.inData()), string(req.inPayload[:i]), req.inPayload[i+1:]) +} + +func doRemoveXAttr(server *protocolServer, req *request) { + if server.opts.DisableXAttrs { + req.status = ENOSYS + return + } + req.status = server.fileSystem.RemoveXAttr(req.cancel, req.inHeader(), req.filename()) +} + +func doAccess(server *protocolServer, req *request) { + req.status = server.fileSystem.Access(req.cancel, (*AccessIn)(req.inData())) +} + +func doSymlink(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + n1, n2, code := req.filenames() + if !code.Ok() { + req.status = code + return + } + req.status = server.fileSystem.Symlink(req.cancel, req.inHeader(), n2, n1, out) +} + +func doRename(server *protocolServer, req *request) { + n1, n2, code := req.filenames() + if !code.Ok() { + req.status = code + return + } + req.status = server.fileSystem.Rename(req.cancel, (*RenameIn)(req.inData()), n1, n2) +} + +func doStatFs(server *protocolServer, req *request) { + out := (*StatfsOut)(req.outData()) + req.status = server.fileSystem.StatFs(req.cancel, req.inHeader(), out) + if req.status == ENOSYS && runtime.GOOS == "darwin" { + // OSX FUSE requires Statfs to be implemented for the + // mount to succeed. + *out = StatfsOut{} + req.status = OK + } +} + +func doIoctl(server *protocolServer, req *request) { + req.status = server.fileSystem.Ioctl(req.cancel, (*IoctlIn)(req.inData()), req.inPayload, (*IoctlOut)(req.outData()), + req.outPayload) +} + +func doPoll(server *protocolServer, req *request) { + req.status = ENOSYS +} + +func doDestroy(server *protocolServer, req *request) { + req.status = OK +} + +func doFallocate(server *protocolServer, req *request) { + req.status = server.fileSystem.Fallocate(req.cancel, (*FallocateIn)(req.inData())) +} + +func doGetLk(server *protocolServer, req *request) { + req.status = server.fileSystem.GetLk(req.cancel, (*LkIn)(req.inData()), (*LkOut)(req.outData())) +} + +func doSetLk(server *protocolServer, req *request) { + req.status = server.fileSystem.SetLk(req.cancel, (*LkIn)(req.inData())) +} + +func doSetLkw(server *protocolServer, req *request) { + req.status = server.fileSystem.SetLkw(req.cancel, (*LkIn)(req.inData())) +} + +func doLseek(server *protocolServer, req *request) { + in := (*LseekIn)(req.inData()) + out := (*LseekOut)(req.outData()) + req.status = server.fileSystem.Lseek(req.cancel, in, out) +} + +func doCopyFileRange(server *protocolServer, req *request) { + in := (*CopyFileRangeIn)(req.inData()) + out := (*WriteOut)(req.outData()) + + out.Size, req.status = server.fileSystem.CopyFileRange(req.cancel, in) +} + +func doInterrupt(server *protocolServer, req *request) { + input := (*InterruptIn)(req.inData()) + req.status = server.interruptRequest(input.Unique) +} + +//////////////////////////////////////////////////////////////// + +type operationFunc func(*protocolServer, *request) +type castPointerFunc func(unsafe.Pointer) any + +type operationHandler struct { + OpCode int + Name string + Func operationFunc + InputSize uintptr + OutputSize uintptr + + InType any + OutType any + FileNames int + FileNameOut bool + SuppressReply bool +} + +var operationHandlers []*operationHandler + +func operationName(op uint32) string { + h := getHandler(op) + if h == nil { + return fmt.Sprintf("opcode %d", op) + } + return h.Name +} + +func getHandler(o uint32) *operationHandler { + if o >= _OPCODE_COUNT { + return nil + } + return operationHandlers[o] +} + +// Maximum size of all input headers. +var maxInputSize uintptr + +func init() { + operationHandlers = make([]*operationHandler, _OPCODE_COUNT) + for i := range operationHandlers { + operationHandlers[i] = &operationHandler{ + OpCode: i, + Name: fmt.Sprintf("OPCODE-%d", i), + } + } + + fileOps := []uint32{_OP_READLINK, _OP_NOTIFY_INVAL_ENTRY, _OP_NOTIFY_DELETE} + for _, op := range fileOps { + operationHandlers[op].FileNameOut = true + } + for _, op := range []uint32{_OP_FORGET, _OP_BATCH_FORGET, _OP_NOTIFY_REPLY} { + operationHandlers[op].SuppressReply = true + } + + for op, v := range map[uint32]string{ + _OP_LOOKUP: "LOOKUP", + _OP_FORGET: "FORGET", + _OP_BATCH_FORGET: "BATCH_FORGET", + _OP_GETATTR: "GETATTR", + _OP_SETATTR: "SETATTR", + _OP_READLINK: "READLINK", + _OP_SYMLINK: "SYMLINK", + _OP_MKNOD: "MKNOD", + _OP_MKDIR: "MKDIR", + _OP_UNLINK: "UNLINK", + _OP_RMDIR: "RMDIR", + _OP_RENAME: "RENAME", + _OP_LINK: "LINK", + _OP_OPEN: "OPEN", + _OP_READ: "READ", + _OP_WRITE: "WRITE", + _OP_STATFS: "STATFS", + _OP_RELEASE: "RELEASE", + _OP_FSYNC: "FSYNC", + _OP_SETXATTR: "SETXATTR", + _OP_GETXATTR: "GETXATTR", + _OP_LISTXATTR: "LISTXATTR", + _OP_REMOVEXATTR: "REMOVEXATTR", + _OP_FLUSH: "FLUSH", + _OP_INIT: "INIT", + _OP_OPENDIR: "OPENDIR", + _OP_READDIR: "READDIR", + _OP_RELEASEDIR: "RELEASEDIR", + _OP_FSYNCDIR: "FSYNCDIR", + _OP_GETLK: "GETLK", + _OP_SETLK: "SETLK", + _OP_SETLKW: "SETLKW", + _OP_ACCESS: "ACCESS", + _OP_CREATE: "CREATE", + _OP_INTERRUPT: "INTERRUPT", + _OP_BMAP: "BMAP", + _OP_DESTROY: "DESTROY", + _OP_IOCTL: "IOCTL", + _OP_POLL: "POLL", + _OP_NOTIFY_REPLY: "NOTIFY_REPLY", + _OP_NOTIFY_INVAL_ENTRY: "NOTIFY_INVAL_ENTRY", + _OP_NOTIFY_INVAL_INODE: "NOTIFY_INVAL_INODE", + _OP_NOTIFY_STORE_CACHE: "NOTIFY_STORE", + _OP_NOTIFY_RETRIEVE_CACHE: "NOTIFY_RETRIEVE", + _OP_NOTIFY_DELETE: "NOTIFY_DELETE", + _OP_FALLOCATE: "FALLOCATE", + _OP_READDIRPLUS: "READDIRPLUS", + _OP_RENAME2: "RENAME2", + _OP_LSEEK: "LSEEK", + _OP_COPY_FILE_RANGE: "COPY_FILE_RANGE", + _OP_SETUPMAPPING: "SETUPMAPPING", + _OP_REMOVEMAPPING: "REMOVEMAPPING", + _OP_SYNCFS: "SYNCFS", + _OP_TMPFILE: "TMPFILE", + _OP_COPY_FILE_RANGE_64: "COPY_FILE_RANGE_64", + } { + operationHandlers[op].Name = v + } + + for op, v := range map[uint32]operationFunc{ + _OP_OPEN: doOpen, + _OP_READDIR: doReadDir, + _OP_WRITE: doWrite, + _OP_OPENDIR: doOpenDir, + _OP_CREATE: doCreate, + _OP_TMPFILE: doTmpfile, + _OP_SETATTR: doSetattr, + _OP_GETXATTR: doGetXAttr, + _OP_LISTXATTR: doGetXAttr, + _OP_GETATTR: doGetAttr, + _OP_FORGET: doForget, + _OP_BATCH_FORGET: doBatchForget, + _OP_READLINK: doReadlink, + _OP_INIT: doInit, + _OP_LOOKUP: doLookup, + _OP_MKNOD: doMknod, + _OP_MKDIR: doMkdir, + _OP_UNLINK: doUnlink, + _OP_RMDIR: doRmdir, + _OP_LINK: doLink, + _OP_READ: doRead, + _OP_FLUSH: doFlush, + _OP_RELEASE: doRelease, + _OP_FSYNC: doFsync, + _OP_RELEASEDIR: doReleaseDir, + _OP_FSYNCDIR: doFsyncDir, + _OP_SETXATTR: doSetXAttr, + _OP_REMOVEXATTR: doRemoveXAttr, + _OP_GETLK: doGetLk, + _OP_SETLK: doSetLk, + _OP_SETLKW: doSetLkw, + _OP_ACCESS: doAccess, + _OP_SYMLINK: doSymlink, + _OP_RENAME: doRename, + _OP_STATFS: doStatFs, + _OP_IOCTL: doIoctl, + _OP_POLL: doPoll, + _OP_DESTROY: doDestroy, + _OP_NOTIFY_REPLY: doNotifyReply, + _OP_FALLOCATE: doFallocate, + _OP_READDIRPLUS: doReadDirPlus, + _OP_RENAME2: doRename, + _OP_INTERRUPT: doInterrupt, + _OP_COPY_FILE_RANGE: doCopyFileRange, + _OP_LSEEK: doLseek, + } { + operationHandlers[op].Func = v + } + + // Outputs. + for op, f := range map[uint32]any{ + _OP_BMAP: _BmapOut{}, + _OP_COPY_FILE_RANGE: WriteOut{}, + _OP_CREATE: CreateOut{}, + _OP_TMPFILE: CreateOut{}, + _OP_GETATTR: AttrOut{}, + _OP_GETLK: LkOut{}, + _OP_GETXATTR: GetXAttrOut{}, + _OP_INIT: InitOut{}, + _OP_IOCTL: IoctlOut{}, + _OP_LINK: EntryOut{}, + _OP_LISTXATTR: GetXAttrOut{}, + _OP_LOOKUP: EntryOut{}, + _OP_LSEEK: LseekOut{}, + _OP_MKDIR: EntryOut{}, + _OP_MKNOD: EntryOut{}, + _OP_NOTIFY_DELETE: NotifyInvalDeleteOut{}, + _OP_NOTIFY_INVAL_ENTRY: NotifyInvalEntryOut{}, + _OP_NOTIFY_INVAL_INODE: NotifyInvalInodeOut{}, + _OP_NOTIFY_RETRIEVE_CACHE: NotifyRetrieveOut{}, + _OP_NOTIFY_STORE_CACHE: NotifyStoreOut{}, + _OP_NOTIFY_PRUNE: NotifyPruneOut{}, + _OP_OPEN: OpenOut{}, + _OP_OPENDIR: OpenOut{}, + _OP_POLL: _PollOut{}, + _OP_SETATTR: AttrOut{}, + _OP_STATFS: StatfsOut{}, + _OP_SYMLINK: EntryOut{}, + _OP_WRITE: WriteOut{}, + _OP_COPY_FILE_RANGE_64: CopyFileRangeOut{}, + } { + operationHandlers[op].OutType = f + operationHandlers[op].OutputSize = typSize(f) + } + + // Inputs. + for op, f := range map[uint32]any{ + _OP_ACCESS: AccessIn{}, + _OP_BATCH_FORGET: _BatchForgetIn{}, + _OP_BMAP: _BmapIn{}, + _OP_COPY_FILE_RANGE: CopyFileRangeIn{}, + _OP_CREATE: CreateIn{}, + _OP_TMPFILE: CreateIn{}, + _OP_FALLOCATE: FallocateIn{}, + _OP_FLUSH: FlushIn{}, + _OP_FORGET: ForgetIn{}, + _OP_FSYNC: FsyncIn{}, + _OP_FSYNCDIR: FsyncIn{}, + _OP_GETATTR: GetAttrIn{}, + _OP_GETLK: LkIn{}, + _OP_GETXATTR: GetXAttrIn{}, + _OP_INIT: InitIn{}, + _OP_INTERRUPT: InterruptIn{}, + _OP_IOCTL: IoctlIn{}, + _OP_LINK: LinkIn{}, + _OP_LISTXATTR: GetXAttrIn{}, + _OP_LSEEK: LseekIn{}, + _OP_MKDIR: MkdirIn{}, + _OP_MKNOD: MknodIn{}, + _OP_NOTIFY_REPLY: NotifyRetrieveIn{}, + _OP_OPEN: OpenIn{}, + _OP_OPENDIR: OpenIn{}, + _OP_POLL: _PollIn{}, + _OP_READ: ReadIn{}, + _OP_READDIR: ReadIn{}, + _OP_READDIRPLUS: ReadIn{}, + _OP_RELEASE: ReleaseIn{}, + _OP_RELEASEDIR: ReleaseIn{}, + _OP_RENAME2: RenameIn{}, + _OP_RENAME: RenameIn{}, + _OP_SETATTR: SetAttrIn{}, + _OP_SETLK: LkIn{}, + _OP_SETLKW: LkIn{}, + _OP_SETXATTR: SetXAttrIn{}, + _OP_WRITE: WriteIn{}, + _OP_COPY_FILE_RANGE_64: CopyFileRangeIn{}, + } { + operationHandlers[op].InType = f + operationHandlers[op].InputSize = typSize(f) + } + + // File name args. + for op, count := range map[uint32]int{ + _OP_CREATE: 1, + // TMPFILE goes through the same kernel code as CREATE, so it carries a name as well: the name of the + // anonymous dentry the kernel makes for the file, which is always "/" and names nothing. + _OP_TMPFILE: 1, + _OP_SETXATTR: 1, + _OP_GETXATTR: 1, + _OP_LINK: 1, + _OP_LOOKUP: 1, + _OP_MKDIR: 1, + _OP_MKNOD: 1, + _OP_REMOVEXATTR: 1, + _OP_RENAME: 2, + _OP_RENAME2: 2, + _OP_RMDIR: 1, + _OP_SYMLINK: 2, + _OP_UNLINK: 1, + } { + operationHandlers[op].FileNames = count + } + + checkFixedBufferSize() +} + +func checkFixedBufferSize() { + var r requestAlloc + for code, h := range operationHandlers { + if h.OutputSize > unsafe.Sizeof(r.outDataInline) { + log.Panicf("request output buffer too small: code %v, sz %d %v", code, h.OutputSize, h) + } + if maxInputSize < h.InputSize { + maxInputSize = h.InputSize + } + } +} diff --git a/third_party/go-fuse/fuse/opcode_darwin.go b/third_party/go-fuse/fuse/opcode_darwin.go new file mode 100644 index 000000000..a3677108c --- /dev/null +++ b/third_party/go-fuse/fuse/opcode_darwin.go @@ -0,0 +1,27 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "unsafe" + +const _OP_MONITOR = uint32(60) + +func doMonitor(_ *protocolServer, req *request) { + // macFUSE sends watcher-monitor notifications for files and directories. + // They are advisory and do not require a reply. + req.status = OK +} + +func init() { + operationHandlers[_OP_MONITOR] = &operationHandler{ + Name: "MONITOR", + Func: doMonitor, + InputSize: unsafe.Sizeof(MonitorIn{}), + InType: MonitorIn{}, + SuppressReply: true, + } + + checkFixedBufferSize() +} diff --git a/third_party/go-fuse/fuse/opcode_linux.go b/third_party/go-fuse/fuse/opcode_linux.go new file mode 100644 index 000000000..204faffd7 --- /dev/null +++ b/third_party/go-fuse/fuse/opcode_linux.go @@ -0,0 +1,26 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "unsafe" + +func doStatx(server *protocolServer, req *request) { + in := (*StatxIn)(req.inData()) + out := (*StatxOut)(req.outData()) + + req.status = server.fileSystem.Statx(req.cancel, in, out) +} + +func init() { + operationHandlers[_OP_STATX] = &operationHandler{ + Name: "STATX", + Func: doStatx, + InType: StatxIn{}, + OutType: StatxOut{}, + InputSize: unsafe.Sizeof(StatxIn{}), + OutputSize: unsafe.Sizeof(StatxOut{}), + } + checkFixedBufferSize() +} diff --git a/third_party/go-fuse/fuse/passthrough_linux.go b/third_party/go-fuse/fuse/passthrough_linux.go new file mode 100644 index 000000000..5efa5d03a --- /dev/null +++ b/third_party/go-fuse/fuse/passthrough_linux.go @@ -0,0 +1,61 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + "unsafe" +) + +const ( + _DEV_IOC_BACKING_OPEN = 0x4010e501 + _DEV_IOC_BACKING_CLOSE = 0x4004e502 +) + +func (r *fuseFD) registerBackingFd(m *BackingMap) (int32, syscall.Errno) { + var id uintptr + var errno syscall.Errno + if cerr := r.withFD(func(fd int) { + id, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(_DEV_IOC_BACKING_OPEN), uintptr(unsafe.Pointer(m))) + }); cerr != nil { + errno = syscall.EBADF + } + if r.server.opts.Debug { + r.server.opts.Logger.Printf("ioctl: BACKING_OPEN %v: id %d (%v)", m.string(), id, errno) + } + return int32(id), errno +} + +func (r *fuseFD) unregisterBackingFd(id int32) syscall.Errno { + var errno syscall.Errno + if cerr := r.withFD(func(fd int) { + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(_DEV_IOC_BACKING_CLOSE), uintptr(unsafe.Pointer(&id))) + }); cerr != nil { + errno = syscall.EBADF + } + + if r.server.opts.Debug { + r.server.opts.Logger.Printf("ioctl: BACKING_CLOSE id %d: %v", id, errno) + } + return errno +} + +// RegisterBackingFd registers the given file descriptor in the +// kernel, so the kernel can bypass FUSE and access the backing file +// directly for read and write calls. On success a backing ID is +// returned. The backing ID should unregistered using +// UnregisterBackingFd() once the file is released. Within the +// kernel, an inode can only have a single backing file, so multiple +// Open/Create calls should coordinate to return a consistent backing +// ID. +func (ms *Server) RegisterBackingFd(m *BackingMap) (int32, syscall.Errno) { + return ms.fuseFD.registerBackingFd(m) +} + +// UnregisterBackingFd unregisters the given ID in the kernel. The ID +// should have been acquired before using RegisterBackingFd. +func (ms *Server) UnregisterBackingFd(id int32) syscall.Errno { + return ms.fuseFD.unregisterBackingFd(id) +} diff --git a/third_party/go-fuse/fuse/poll.go b/third_party/go-fuse/fuse/poll.go new file mode 100644 index 000000000..937d1abbb --- /dev/null +++ b/third_party/go-fuse/fuse/poll.go @@ -0,0 +1,52 @@ +package fuse + +// Go 1.9 introduces polling for file I/O. The implementation causes +// the runtime's epoll to take up the last GOMAXPROCS slot, and if +// that happens, we won't have any threads left to service FUSE's +// _OP_POLL request. Prevent this by forcing _OP_POLL to happen, so we +// can say ENOSYS and prevent further _OP_POLL requests. +const pollHackName = ".go-fuse-epoll-hack" +const pollHackInode = ^uint64(0) + +func doPollHackLookup(ms *protocolServer, req *request) { + attr := Attr{ + Ino: pollHackInode, + Mode: S_IFREG | 0644, + Nlink: 1, + } + switch req.inHeader().Opcode { + case _OP_LOOKUP: + out := (*EntryOut)(req.outData()) + *out = EntryOut{ + NodeId: pollHackInode, + Attr: attr, + } + req.status = OK + case _OP_OPEN: + out := (*OpenOut)(req.outData()) + *out = OpenOut{ + Fh: pollHackInode, + } + req.status = OK + case _OP_GETATTR, _OP_SETATTR: + out := (*AttrOut)(req.outData()) + out.Attr = attr + req.status = OK + case _OP_GETXATTR: + // Kernel will try to read acl xattrs. Pretend we don't have any. + req.status = ENODATA + case _OP_POLL: + req.status = ENOSYS + + case _OP_ACCESS, _OP_FLUSH, _OP_RELEASE: + // Avoid upsetting the OSX mount process. + req.status = OK + default: + // We want to avoid switching off features through our + // poll hack, so don't use ENOSYS. It would be nice if + // we could transmit no error code at all, but for + // some opcodes, we'd have to invent credible data to + // return as well. + req.status = ERANGE + } +} diff --git a/third_party/go-fuse/fuse/poll_darwin.go b/third_party/go-fuse/fuse/poll_darwin.go new file mode 100644 index 000000000..802190002 --- /dev/null +++ b/third_party/go-fuse/fuse/poll_darwin.go @@ -0,0 +1,56 @@ +package fuse + +import ( + "path/filepath" + "syscall" + "unsafe" +) + +type pollFd struct { + Fd int32 + Events int16 + Revents int16 +} + +func sysPoll(fds []pollFd, timeout int) (n int, err error) { + r0, _, e1 := syscall.Syscall(syscall.SYS_POLL, uintptr(unsafe.Pointer(&fds[0])), + uintptr(len(fds)), uintptr(timeout)) + n = int(r0) + if e1 != 0 { + err = syscall.Errno(e1) + } + return n, err +} + +func pollHack(mountPoint string) error { + const ( + POLLIN = 0x1 + POLLPRI = 0x2 + POLLOUT = 0x4 + POLLRDHUP = 0x2000 + POLLERR = 0x8 + POLLHUP = 0x10 + ) + + fd, err := syscall.Open(filepath.Join(mountPoint, pollHackName), syscall.O_RDONLY, 0) + if err == syscall.EPERM { + // This can happen due to macos sandboxing, see + // https://github.com/hanwen/go-fuse/issues/572#issuecomment-2904052441 + // If we don't have access to access our own mount + // point, we also can't deadlock ourselves with poll, so this is fine. + return nil + } + if err != nil { + return err + } + pollData := []pollFd{{ + Fd: int32(fd), + Events: POLLIN | POLLPRI | POLLOUT, + }} + + // Trigger _OP_POLL, so we can say ENOSYS. We don't care about + // the return value. + sysPoll(pollData, 0) + syscall.Close(fd) + return nil +} diff --git a/third_party/go-fuse/fuse/poll_unix.go b/third_party/go-fuse/fuse/poll_unix.go new file mode 100644 index 000000000..17734e722 --- /dev/null +++ b/third_party/go-fuse/fuse/poll_unix.go @@ -0,0 +1,27 @@ +//go:build !darwin + +package fuse + +import ( + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +func pollHack(mountPoint string) error { + fd, err := syscall.Open(filepath.Join(mountPoint, pollHackName), syscall.O_RDONLY, 0) + if err != nil { + return err + } + pollData := []unix.PollFd{{ + Fd: int32(fd), + Events: unix.POLLIN | unix.POLLPRI | unix.POLLOUT, + }} + + // Trigger _OP_POLL, so we can say ENOSYS. We don't care about + // the return value. + unix.Poll(pollData, 0) + syscall.Close(fd) + return nil +} diff --git a/third_party/go-fuse/fuse/print.go b/third_party/go-fuse/fuse/print.go new file mode 100644 index 000000000..96c6b4ee9 --- /dev/null +++ b/third_party/go-fuse/fuse/print.go @@ -0,0 +1,446 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" + "os" + "strings" + "syscall" +) + +var ( + isTest bool + writeFlagNames = newFlagNames([]flagNameEntry{ + {WRITE_CACHE, "CACHE"}, + {WRITE_LOCKOWNER, "LOCKOWNER"}, + {WRITE_KILL_SUIDGID, "KILL_SUIDGID"}, + }) + readFlagNames = newFlagNames([]flagNameEntry{ + {READ_LOCKOWNER, "LOCKOWNER"}, + }) + initFlagNames = newFlagNames([]flagNameEntry{ + {CAP_ASYNC_READ, "ASYNC_READ"}, + {CAP_POSIX_LOCKS, "POSIX_LOCKS"}, + {CAP_FILE_OPS, "FILE_OPS"}, + {CAP_ATOMIC_O_TRUNC, "ATOMIC_O_TRUNC"}, + {CAP_EXPORT_SUPPORT, "EXPORT_SUPPORT"}, + {CAP_BIG_WRITES, "BIG_WRITES"}, + {CAP_DONT_MASK, "DONT_MASK"}, + {CAP_SPLICE_WRITE, "SPLICE_WRITE"}, + {CAP_SPLICE_MOVE, "SPLICE_MOVE"}, + {CAP_SPLICE_READ, "SPLICE_READ"}, + {CAP_FLOCK_LOCKS, "FLOCK_LOCKS"}, + {CAP_IOCTL_DIR, "IOCTL_DIR"}, + {CAP_AUTO_INVAL_DATA, "AUTO_INVAL_DATA"}, + {CAP_READDIRPLUS, "READDIRPLUS"}, + {CAP_READDIRPLUS_AUTO, "READDIRPLUS_AUTO"}, + {CAP_ASYNC_DIO, "ASYNC_DIO"}, + {CAP_WRITEBACK_CACHE, "WRITEBACK_CACHE"}, + {CAP_NO_OPEN_SUPPORT, "NO_OPEN_SUPPORT"}, + {CAP_PARALLEL_DIROPS, "PARALLEL_DIROPS"}, + {CAP_POSIX_ACL, "POSIX_ACL"}, + {CAP_HANDLE_KILLPRIV, "HANDLE_KILLPRIV"}, + {CAP_ABORT_ERROR, "ABORT_ERROR"}, + {CAP_MAX_PAGES, "MAX_PAGES"}, + {CAP_CACHE_SYMLINKS, "CACHE_SYMLINKS"}, + {CAP_SECURITY_CTX, "SECURITY_CTX"}, + {CAP_HAS_INODE_DAX, "HAS_INODE_DAX"}, + {CAP_CREATE_SUPP_GROUP, "CREATE_SUPP_GROUP"}, + {CAP_HAS_EXPIRE_ONLY, "HAS_EXPIRE_ONLY"}, + {CAP_DIRECT_IO_ALLOW_MMAP, "DIRECT_IO_ALLOW_MMAP"}, + {CAP_PASSTHROUGH, "PASSTHROUGH"}, + {CAP_NO_EXPORT_SUPPORT, "NO_EXPORT_SUPPORT"}, + {CAP_HAS_RESEND, "HAS_RESEND"}, + {CAP_ALLOW_IDMAP, "ALLOW_IDMAP"}, + {CAP_OVER_IO_URING, "IO_URING"}, + {CAP_REQUEST_TIMEOUT, "REQUEST_TIMEOUT"}, + }) + releaseFlagNames = newFlagNames([]flagNameEntry{ + {RELEASE_FLUSH, "FLUSH"}, + }) + openFlagNames = newFlagNames([]flagNameEntry{ + {int64(os.O_WRONLY), "WRONLY"}, + {int64(os.O_RDWR), "RDWR"}, + {int64(os.O_APPEND), "APPEND"}, + {int64(syscall.O_ASYNC), "ASYNC"}, + {int64(os.O_CREATE), "CREAT"}, + {int64(os.O_EXCL), "EXCL"}, + {int64(syscall.O_NOCTTY), "NOCTTY"}, + {int64(syscall.O_NONBLOCK), "NONBLOCK"}, + {int64(os.O_SYNC), "SYNC"}, + {int64(os.O_TRUNC), "TRUNC"}, + {int64(syscall.O_CLOEXEC), "CLOEXEC"}, + {int64(syscall.O_DIRECTORY), "DIRECTORY"}, + }) + fuseOpenFlagNames = newFlagNames([]flagNameEntry{ + {FOPEN_DIRECT_IO, "DIRECT"}, + {FOPEN_KEEP_CACHE, "CACHE"}, + {FOPEN_NONSEEKABLE, "NONSEEK"}, + {FOPEN_CACHE_DIR, "CACHE_DIR"}, + {FOPEN_STREAM, "STREAM"}, + {FOPEN_NOFLUSH, "NOFLUSH"}, + {FOPEN_PARALLEL_DIRECT_WRITES, "PARALLEL_DIRECT_WRITES"}, + {FOPEN_PASSTHROUGH, "PASSTHROUGH"}, + }) + fuseOpenInFlagNames = newFlagNames([]flagNameEntry{ + {OPEN_KILL_SUIDGID, "KILL_SUIDGID"}, + }) + expireFlagNames = newFlagNames([]flagNameEntry{ + {EXPIRE_ONLY, "EXPIRE_ONLY"}, + }) + ioctlFlagNames = newFlagNames([]flagNameEntry{ + {IOCTL_COMPAT, "COMPAT"}, + {IOCTL_UNRESTRICTED, "UNRESTRICTED"}, + {IOCTL_RETRY, "RETRY"}, + {IOCTL_DIR, "DIR"}, + }) + accessFlagName = newFlagNames([]flagNameEntry{ + {X_OK, "x"}, + {W_OK, "w"}, + {R_OK, "r"}, + }) + getAttrFlagNames = newFlagNames([]flagNameEntry{ + {FUSE_GETATTR_FH, "FH"}, + }) + renameFlagNames = newFlagNames([]flagNameEntry{ + {1, "NOREPLACE"}, + {2, "EXCHANGE"}, + {4, "WHITEOUT"}, + }) + lockFlagNames = newFlagNames([]flagNameEntry{ + {(1 << 0), "FLOCK"}, + }) +) + +// flagNames associate flag bits to their names. +type flagNames [64]flagNameEntry + +// flagNameEntry describes one flag value. +// +// Usually a flag constitues only one bit, but, for example at least O_SYNC and +// O_TMPFILE are represented by a value with two bits set. To handle such +// situations we map all bits of a flag to the same flagNameEntry. +type flagNameEntry struct { + bits int64 + name string +} + +// newFlagNames creates flagNames from flag->name map. +func newFlagNames(names []flagNameEntry) *flagNames { + var v flagNames + for _, e := range names { + v.set(e.bits, e.name) + } + return &v +} + +// set associates flag value with name. +func (names *flagNames) set(flag int64, name string) { + entry := flagNameEntry{bits: flag, name: name} + for i := 0; i < 64; i++ { + if flag&(1< float(seconds) +func ft(tsec uint64, tnsec uint32) float64 { + return float64(tsec) + float64(tnsec)*1e-9 +} + +// Returned by LOOKUP +func (o *EntryOut) string() string { + return fmt.Sprintf("{n%d g%d tE=%gs tA=%gs %v}", + o.NodeId, o.Generation, ft(o.EntryValid, o.EntryValidNsec), + ft(o.AttrValid, o.AttrValidNsec), &o.Attr) +} + +func (o *CreateOut) string() string { + return fmt.Sprintf("{n%d g%d %v %v}", o.NodeId, o.Generation, &o.EntryOut, o.OpenOut.string()) +} + +func (o *StatfsOut) string() string { + return fmt.Sprintf( + "{blocks (%d,%d)/%d files %d/%d bs%d nl%d frs%d}", + o.Bfree, o.Bavail, o.Blocks, o.Ffree, o.Files, + o.Bsize, o.NameLen, o.Frsize) +} + +func (o *NotifyInvalEntryOut) string() string { + return fmt.Sprintf("{parent i%d sz %d%s}", o.Parent, o.NameLen, + optFlagString(expireFlagNames, int64(o.Flags))) +} + +func (o *NotifyInvalInodeOut) string() string { + return fmt.Sprintf("{i%d [%d +%d)}", o.Ino, o.Off, o.Length) +} + +func (o *NotifyInvalDeleteOut) string() string { + return fmt.Sprintf("{parent i%d ch i%d sz %d}", o.Parent, o.Child, o.NameLen) +} + +func (o *NotifyStoreOut) string() string { + return fmt.Sprintf("{n%d [%d +%d)}", o.Nodeid, o.Offset, o.Size) +} + +func (o *NotifyRetrieveOut) string() string { + return fmt.Sprintf("{> %d: n%d [%d +%d)}", o.NotifyUnique, o.Nodeid, o.Offset, o.Size) +} + +func (i *NotifyRetrieveIn) string() string { + return fmt.Sprintf("{[%d +%d)}", i.Offset, i.Size) +} + +func (o *NotifyPruneOut) string() string { + return fmt.Sprintf("{%d}", o.Count) +} + +func (f *FallocateIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) mod 0%o}", + f.Fh, f.Offset, f.Length, f.Mode) +} + +func (f *LinkIn) string() string { + return fmt.Sprintf("{Oldnodeid: n%d}", f.Oldnodeid) +} + +func (o *WriteOut) string() string { + return fmt.Sprintf("{%db }", o.Size) + +} +func (i *CopyFileRangeIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) => n%d Fh %d [%d, %d)}", + i.FhIn, i.OffIn, i.Len, i.NodeIdOut, i.FhOut, i.OffOut, i.Len) +} + +func (in *InterruptIn) string() string { + return fmt.Sprintf("{ix %d}", in.Unique) +} + +var seekNames = map[uint32]string{ + 0: "SET", + 1: "CUR", + 2: "END", + 3: "DATA", + 4: "HOLE", +} + +func (in *LseekIn) string() string { + return fmt.Sprintf("{Fh %d [%s +%d)}", in.Fh, + seekNames[in.Whence], in.Offset) +} + +func (o *LseekOut) string() string { + return fmt.Sprintf("{%d}", o.Offset) +} + +func (p *_PollIn) string() string { + return fmt.Sprintf("Fh %d Kh %d Flags 0x%x", p.Fh, p.Kh, p.Flags) +} + +// Print pretty prints FUSE data types for kernel communication +func Print(obj any) string { + t, ok := obj.(interface { + string() string + }) + if ok { + return t.string() + } + return fmt.Sprintf("%T: %v", obj, obj) +} + +func (a *Attr) string() string { + return fmt.Sprintf( + "{M0%o SZ=%d L=%d "+ + "%d:%d "+ + "B%d*%d i%d:%d "+ + "A %f "+ + "M %f "+ + "C %f%s}", + a.Mode, a.Size, a.Nlink, + a.Uid, a.Gid, + a.Blocks, a.Blksize, + a.Rdev, a.Ino, ft(a.Atime, a.Atimensec), ft(a.Mtime, a.Mtimensec), + ft(a.Ctime, a.Ctimensec), a.flagsString()) +} + +func (m *BackingMap) string() string { + return fmt.Sprintf("{fd %d, flags 0x%x}", m.Fd, m.Flags) +} + +func (o *IoctlIn) string() string { + return fmt.Sprintf("{Fh %d Flags %s Cmd 0x%x Arg 0x%x, insz %d outsz %d}", + o.Fh, + flagString(ioctlFlagNames, int64(o.Flags), ""), + o.Cmd, o.Arg, o.InSize, o.OutSize) +} + +func (o *IoctlOut) string() string { + return fmt.Sprintf("{Result %d Flags %s Iovs %d/%d", + o.Result, + flagString(ioctlFlagNames, int64(o.Flags), ""), + o.InIovs, o.OutIovs) +} + +func (l *LkIn) string() string { + return fmt.Sprintf("{Fh %d Owner %d %s %s}", l.Owner, l.Owner, l.Lk.string(), flagString(lockFlagNames, int64(l.LkFlags), "")) +} + +func (lk *FileLock) string() string { + return fmt.Sprintf("{[%d,%d) t%d p%d}", lk.Start, lk.End, lk.Typ, lk.Pid) +} + +func (l *LkOut) string() string { + return l.Lk.string() +} diff --git a/third_party/go-fuse/fuse/print_darwin.go b/third_party/go-fuse/fuse/print_darwin.go new file mode 100644 index 000000000..7f90cd0e9 --- /dev/null +++ b/third_party/go-fuse/fuse/print_darwin.go @@ -0,0 +1,50 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" +) + +func init() { + initFlagNames.set(CAP_NODE_RWLOCK, "NODE_RWLOCK") + initFlagNames.set(CAP_RENAME_SWAP, "RENAME_SWAP") + initFlagNames.set(CAP_RENAME_EXCL, "RENAME_EXCL") + initFlagNames.set(CAP_ALLOCATE, "ALLOCATE") + initFlagNames.set(CAP_EXCHANGE_DATA, "EXCHANGE_DATA") + initFlagNames.set(CAP_XTIMES, "XTIMES") + initFlagNames.set(CAP_VOL_RENAME, "VOL_RENAME") + initFlagNames.set(CAP_CASE_INSENSITIVE, "CASE_INSENSITIVE") +} + +func (me *CreateIn) string() string { + return fmt.Sprintf( + "{0%o [%s]}", me.Mode, + flagString(openFlagNames, int64(me.Flags), "O_RDONLY")) +} + +func (me *GetAttrIn) string() string { return "" } + +func (a *Attr) flagsString() string { return "" } + +func (in *SetXAttrIn) string() string { + return fmt.Sprintf("{sz %d f%o}", in.Size, in.Flags) +} + +func (me *MknodIn) string() string { + return fmt.Sprintf("{0%o, %d}", me.Mode, me.Rdev) +} + +func (me *ReadIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s}", + me.Fh, me.Offset, me.Size, + flagString(readFlagNames, int64(me.ReadFlags), "")) +} + +func (me *WriteIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s}", + me.Fh, me.Offset, me.Size, + flagString(writeFlagNames, int64(me.WriteFlags), "")) +} diff --git a/third_party/go-fuse/fuse/print_freebsd.go b/third_party/go-fuse/fuse/print_freebsd.go new file mode 100644 index 000000000..61a6c1367 --- /dev/null +++ b/third_party/go-fuse/fuse/print_freebsd.go @@ -0,0 +1,5 @@ +package fuse + +func init() { + initFlagNames.set(CAP_NO_OPENDIR_SUPPORT, "NO_OPENDIR_SUPPORT") +} diff --git a/third_party/go-fuse/fuse/print_linux.go b/third_party/go-fuse/fuse/print_linux.go new file mode 100644 index 000000000..2d896c5e1 --- /dev/null +++ b/third_party/go-fuse/fuse/print_linux.go @@ -0,0 +1,92 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" + "runtime" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +var statxFieldFlags = newFlagNames([]flagNameEntry{ + {unix.STATX_ATIME, "Atime"}, + {unix.STATX_BLOCKS, "blocks"}, + {unix.STATX_BTIME, "Btime"}, + {unix.STATX_CTIME, "Ctime"}, + {unix.STATX_GID, "Gid"}, + {unix.STATX_INO, "Ino"}, + {unix.STATX_MNT_ID, "Mntid"}, + {unix.STATX_MODE, "Mode"}, + {unix.STATX_MTIME, "Mtime"}, + {unix.STATX_NLINK, "Nlink"}, + {unix.STATX_SIZE, "Size"}, + {unix.STATX_TYPE, "Type"}, + {unix.STATX_UID, "Uid"}, +}) + +func init() { + // syscall.O_LARGEFILE is 0x0 on x86_64, but the kernel + // supplies 0x8000 anyway, except on mips64el, where 0x8000 is + // used for O_DIRECT. + if !strings.Contains(runtime.GOARCH, "mips64") { + openFlagNames.set(0x8000, "LARGEFILE") + } + + openFlagNames.set(syscall.O_DIRECT, "DIRECT") + openFlagNames.set(syscall_O_NOATIME, "NOATIME") + openFlagNames.set(FMODE_EXEC, "EXEC") + initFlagNames.set(CAP_NO_OPENDIR_SUPPORT, "NO_OPENDIR_SUPPORT") + initFlagNames.set(CAP_EXPLICIT_INVAL_DATA, "EXPLICIT_INVAL_DATA") + initFlagNames.set(CAP_MAP_ALIGNMENT, "MAP_ALIGNMENT") + initFlagNames.set(CAP_SUBMOUNTS, "SUBMOUNTS") + initFlagNames.set(CAP_HANDLE_KILLPRIV_V2, "HANDLE_KILLPRIV_V2") + initFlagNames.set(CAP_SETXATTR_EXT, "SETXATTR_EXT") + initFlagNames.set(CAP_INIT_EXT, "INIT_EXT") + initFlagNames.set(CAP_INIT_RESERVED, "INIT_RESERVED") +} + +func (a *Statx) string() string { + var ss []string + if a.Mask&unix.STATX_MODE != 0 || a.Mask&unix.STATX_TYPE != 0 { + ss = append(ss, fmt.Sprintf("M0%o", a.Mode)) + } + if a.Mask&unix.STATX_SIZE != 0 { + ss = append(ss, fmt.Sprintf("SZ=%d", a.Size)) + } + if a.Mask&unix.STATX_NLINK != 0 { + ss = append(ss, fmt.Sprintf("L=%d", a.Nlink)) + } + if a.Mask&unix.STATX_UID != 0 || a.Mask&unix.STATX_GID != 0 { + ss = append(ss, fmt.Sprintf("%d:%d", a.Uid, a.Gid)) + } + if a.Mask&unix.STATX_INO != 0 { + ss = append(ss, fmt.Sprintf("i%d", a.Ino)) + } + if a.Mask&unix.STATX_ATIME != 0 { + ss = append(ss, fmt.Sprintf("A %f", a.Atime.Seconds())) + } + if a.Mask&unix.STATX_BTIME != 0 { + ss = append(ss, fmt.Sprintf("B %f", a.Btime.Seconds())) + } + if a.Mask&unix.STATX_CTIME != 0 { + ss = append(ss, fmt.Sprintf("C %f", a.Ctime.Seconds())) + } + if a.Mask&unix.STATX_MTIME != 0 { + ss = append(ss, fmt.Sprintf("M %f", a.Mtime.Seconds())) + } + if a.Mask&unix.STATX_BLOCKS != 0 { + ss = append(ss, fmt.Sprintf("%d*%d", a.Blocks, a.Blksize)) + } + + return "{" + strings.Join(ss, " ") + "}" +} + +func (in *StatxIn) string() string { + return fmt.Sprintf("{Fh %d %s 0x%x %s}", in.Fh, flagString(getAttrFlagNames, int64(in.GetattrFlags), ""), + in.SxFlags, flagString(statxFieldFlags, int64(in.SxMask), "")) +} diff --git a/third_party/go-fuse/fuse/print_unix.go b/third_party/go-fuse/fuse/print_unix.go new file mode 100644 index 000000000..36150e1f9 --- /dev/null +++ b/third_party/go-fuse/fuse/print_unix.go @@ -0,0 +1,55 @@ +//go:build !darwin + +package fuse + +import "fmt" + +var ( + attrFlagNames = newFlagNames([]flagNameEntry{ + {ATTR_SUBMOUNT, "SUBMOUNT"}, + {ATTR_DAX, "DAX"}, + }) + setXAttrFlagNames = newFlagNames([]flagNameEntry{ + {SETXATTR_ACL_KILL_SGID, "ACL_KILL_SGID"}, + }) +) + +func (in *CreateIn) string() string { + return fmt.Sprintf( + "{0%o [%s] (0%o)%s}", in.Mode, + flagString(openFlagNames, int64(in.Flags), "O_RDONLY"), in.Umask, + optFlagString(fuseOpenInFlagNames, int64(in.OpenFlags))) +} + +func (a *Attr) flagsString() string { + return optFlagString(attrFlagNames, int64(a.Flags)) +} + +func (in *SetXAttrIn) string() string { + return fmt.Sprintf("{sz %d f%o%s}", in.Size, in.Flags, + optFlagString(setXAttrFlagNames, int64(in.SetXAttrFlags))) +} + +func (in *GetAttrIn) string() string { + return fmt.Sprintf("{Fh %d %s}", in.Fh_, flagString(getAttrFlagNames, int64(in.Flags_), "")) +} + +func (in *MknodIn) string() string { + return fmt.Sprintf("{0%o (0%o), %d}", in.Mode, in.Umask, in.Rdev) +} + +func (in *ReadIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s L %d %s}", + in.Fh, in.Offset, in.Size, + flagString(readFlagNames, int64(in.ReadFlags), ""), + in.LockOwner, + flagString(openFlagNames, int64(in.Flags), "RDONLY")) +} + +func (in *WriteIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s L %d %s}", + in.Fh, in.Offset, in.Size, + flagString(writeFlagNames, int64(in.WriteFlags), ""), + in.LockOwner, + flagString(openFlagNames, int64(in.Flags), "RDONLY")) +} diff --git a/third_party/go-fuse/fuse/protocol-server.go b/third_party/go-fuse/fuse/protocol-server.go new file mode 100644 index 000000000..96f5b0733 --- /dev/null +++ b/third_party/go-fuse/fuse/protocol-server.go @@ -0,0 +1,437 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "sync" + "syscall" + "unsafe" +) + +// protocolServer bridges from the FUSE datatypes to a RawFileSystem +type protocolServer struct { + fileSystem RawFileSystem + + writev func([][]byte) (int, syscall.Errno) + + interruptMu sync.Mutex + reqInflight []*request + connectionDead bool + + kernelSettings InitIn + + // If set, handleRequest does not flatten withReadv results + // with Bytes(), but leaves them in req.readResult, so + // ProtocolServer.HandleRequest can Readv them straight into + // the reply iov. + useReadv bool + + // Capabilities sent in InitOut. + negotiatedFlags uint64 + + opts *MountOptions + + // Pools for []byte + buffers bufferPool + + writevCopyOnce sync.Once + + // in-flight notify-retrieve queries + retrieveMu sync.Mutex + retrieveNext uint64 + retrieveTab map[uint64]*retrieveCacheRequest // notifyUnique -> retrieve request +} + +func (ms *protocolServer) handleRequest(h *operationHandler, req *request) { + ms.addInflight(req) + defer ms.dropInflight(req) + + if req.status.Ok() && ms.opts.Debug { + ms.opts.Logger.Println(req.InputDebug()) + } + + if h == nil || h.Func == nil { + c := req.inHeader().Opcode + if c != _OP_COPY_FILE_RANGE_64 { // _OP_COPY_FILE_RANGE_64 is intentionally not supported. + ms.opts.Logger.Printf("Unimplemented opcode %v", operationName(c)) + } + req.status = ENOSYS + } else if !req.status.Ok() { + // Reply with the error from parsing the request. + } else if req.inHeader().NodeId == pollHackInode || + req.inHeader().NodeId == FUSE_ROOT_ID && h.FileNames > 0 && req.filename() == pollHackName { + doPollHackLookup(ms, req) + } else { + func() { + defer func() { + if r := recover(); r != nil { + req.status = ms.opts.PanicHandler(r) + if req.status == 0 { + req.status = EIO + } + } + }() + h.Func(ms, req) + }() + } + + // Forget/NotifyReply do not wait for reply from filesystem server. + switch req.inHeader().Opcode { + case _OP_INTERRUPT: + // ? what other status can interrupt generate? + if req.status.Ok() { + req.suppressReply = true + } + default: + req.suppressReply = h != nil && h.SuppressReply + } + if req.status == EINTR { + ms.interruptMu.Lock() + dead := ms.connectionDead + ms.interruptMu.Unlock() + if dead { + req.suppressReply = true + } + } + if req.suppressReply { + return + } + if req.readResult != nil && ms.opts.DisableSplice { + _, vectored := req.readResult.(withSlice) + _, readv := req.readResult.(withReadv) + if !vectored && !(readv && ms.useReadv) { + req.outPayload, req.status = req.readResult.Bytes(req.outPayload) + req.readResult.Done() + req.readResult = nil + } + } + if req.status > OK && req.readResult != nil { + req.readResult.Done() + req.readResult = nil + } + + req.serializeHeader(req.outPayloadSize()) + + if ms.opts.Debug { + ms.opts.Logger.Println(req.OutputDebug()) + } +} + +func (ms *protocolServer) addInflight(req *request) { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + req.inflightIndex = len(ms.reqInflight) + ms.reqInflight = append(ms.reqInflight, req) +} + +func (ms *protocolServer) dropInflight(req *request) { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + this := req.inflightIndex + last := len(ms.reqInflight) - 1 + if last != this { + ms.reqInflight[this] = ms.reqInflight[last] + ms.reqInflight[this].inflightIndex = this + } + ms.reqInflight = ms.reqInflight[:last] +} + +func (ms *protocolServer) interruptRequest(unique uint64) Status { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + + // This is slow, but this operation is rare. + for _, inflight := range ms.reqInflight { + if unique == inflight.inHeader().Unique && !inflight.interrupted { + close(inflight.cancel) + inflight.interrupted = true + return OK + } + } + + return EAGAIN +} + +func (ms *protocolServer) cancelAll() { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + ms.connectionDead = true + for _, req := range ms.reqInflight { + if !req.interrupted { + close(req.cancel) + req.interrupted = true + } + } + // Leave ms.reqInflight alone, or dropInflight will barf. +} + +// ProtocolServer bridges from FUSE request/response types to the +// Go-FUSE RawFileSystem API calls. +// +// EXPERIMENTAL: not subject to API stability. +type ProtocolServer struct { + protocolServer +} + +// NewProtocolServer creates a ProtocolServer for the RawFileSystem. +// +// EXPERIMENTAL: not subject to API stability. +func NewProtocolServer(fs RawFileSystem, opts *MountOptions) *ProtocolServer { + // ProtocolServer has no pipe, so splicing READ results to the + // caller is not possible; force the in-process READ path. + optsCopy := *opts + optsCopy.setDefaults(fs) + optsCopy.DisableSplice = true + + return &ProtocolServer{ + protocolServer: protocolServer{ + fileSystem: fs, + retrieveTab: make(map[uint64]*retrieveCacheRequest), + opts: &optsCopy, + useReadv: true, + }, + } +} + +func iovLen(iov [][]byte) int { + var r int + for _, e := range iov { + r += len(e) + } + return r +} + +// iovSlice returns iov[off:off+n] if it lies within one element. +func iovSlice(iov [][]byte, off, n int) ([]byte, bool) { + for _, e := range iov { + if off < len(e) { + if off+n <= len(e) { + return e[off : off+n], true + } + return nil, false + } + off -= len(e) + } + return nil, n == 0 +} + +func copyFromIov(dst []byte, iov [][]byte, off int) int { + var n int + for _, e := range iov { + if len(dst) == n { + break + } + if off >= len(e) { + off -= len(e) + continue + } + n += copy(dst[n:], e[off:]) + off = 0 + } + return n +} + +type iovWriter struct { + dst [][]byte + i, off int + written int +} + +func (w *iovWriter) next(n int) []byte { + for w.i < len(w.dst) && w.off == len(w.dst[w.i]) { + w.i++ + w.off = 0 + } + if w.i == len(w.dst) { + return nil + } + chunk := w.dst[w.i][w.off:] + chunk = chunk[:min(len(chunk), n)] + w.off += len(chunk) + w.written += len(chunk) + return chunk +} + +func (w *iovWriter) write(src []byte) int { + var n int + for n < len(src) { + chunk := w.next(len(src) - n) + if chunk == nil { + break + } + n += copy(chunk, src[n:]) + } + return n +} + +func (w *iovWriter) skip(n int) { + for n > 0 { + chunk := w.next(n) + if chunk == nil { + break + } + n -= len(chunk) + } +} + +// sliceIov returns the iov for bytes [off, off+n). +func sliceIov(iov [][]byte, off, n int) [][]byte { + var r [][]byte + for _, e := range iov { + if n == 0 { + break + } + if off >= len(e) { + off -= len(e) + continue + } + e = e[off:] + off = 0 + e = e[:min(len(e), n)] + n -= len(e) + r = append(r, e) + } + return r +} + +func copyToIov(dst [][]byte, src ...[]byte) int { + w := iovWriter{dst: dst} + for _, s := range src { + w.write(s) + } + return w.written +} + +// HandleRequest parses the iov in `in`, calls into the raw +// filesystem, and puts the result in `out`. The iovs are treated +// as byte streams. The return value is the number of bytes written. +// +// EXPERIMENTAL: not subject to API stability. +func (ps *ProtocolServer) HandleRequest(in [][]byte, out [][]byte) (int, Status) { + req := &requestAlloc{ + request: request{ + cancel: make(chan struct{}), + }, + } + + total := iovLen(in) + n := copyFromIov(req.smallInputBuf[:min(total, len(req.smallInputBuf))], in, 0) + if n < int(unsafe.Sizeof(InHeader{})) { + ps.opts.Logger.Printf("request too short: %v", iovLens(in)) + return 0, EIO + } + h, inSize, outSize, outPayloadSize, code := parseRequest(req.smallInputBuf[:n], &ps.kernelSettings, ps.negotiatedFlags) + if !code.Ok() && code != ENOSYS { + ps.opts.Logger.Printf("parseRequest: %v", code) + } + req.inputBuf = req.smallInputBuf[:n] + req.status = code + if code.Ok() { + req.inputBuf = req.inputBuf[:inSize] + payloadLen := total - inSize + if b, ok := iovSlice(in, inSize, payloadLen); ok { + req.inPayload = b + } else if req.inHeader().Opcode == _OP_WRITE { + req.inPayloadIov = sliceIov(in, inSize, payloadLen) + } else { + // IOCTL data comes in one element per page. Other + // payloads (names, xattr values, BATCH_FORGET + // entries, extensions) are only split if they + // cross a guest memory region boundary. + req.inPayload = make([]byte, payloadLen) + copyFromIov(req.inPayload, in, inSize) + } + req.extendInput(int(h.InputSize)) + req.status = req.splitExt(&req.extInline) + if !req.status.Ok() { + ps.opts.Logger.Printf("op %s: bad request extension: %v", h.Name, req.status) + } + } + + req.outHeaderBuf = req.outHeaderInline[:] + req.outDataBuf = req.outDataInline[:outSize] + clear(req.outHeaderBuf) + clear(req.outDataBuf) + + var direct []byte + suppressReply := h != nil && h.SuppressReply + if !suppressReply && req.status.Ok() { + need := int(sizeOfOutHeader) + outSize + outPayloadSize + if iovLen(out) < need { + ps.opts.Logger.Printf("op %v: got out iov %v, need %d bytes", h.Name, iovLens(out), need) + return 0, EIO + } + if outPayloadSize > 0 { + if b, ok := iovSlice(out, int(sizeOfOutHeader)+outSize, outPayloadSize); ok { + direct = b + req.outPayload = b + } else { + req.outPayload = ps.buffers.AllocBuffer(uint32(outPayloadSize)) + defer ps.buffers.FreeBuffer(req.outPayload) + } + } + } + + ps.protocolServer.handleRequest(h, &req.request) + if req.suppressReply { + return 0, OK + } + + // Per the virtio spec, vring_used_elem.len should hold the + // number of bytes the device wrote to the descriptor + // chain. Returning more inflates the live-migration dirty-page + // log and violates the spec. + w := iovWriter{dst: out} + if req.readResult != nil { + // The size is only known after the read, so redo the header. + payloadOff := int(sizeOfOutHeader) + len(req.outDataBuf) + dst := sliceIov(out, payloadOff, req.readResult.Size()) + n, code := readIntoIov(req.readResult, dst) + req.readResult.Done() + req.readResult = nil + if !code.Ok() { + req.status = code + n = 0 + } + req.serializeHeader(n) + w.write(req.outHeaderBuf) + w.write(req.outDataBuf) + w.skip(n) + return w.written, OK + } + + w.write(req.outHeaderBuf) + w.write(req.outDataBuf) + if len(req.outPayload) > 0 && len(direct) > 0 && &req.outPayload[0] == &direct[0] { + // The filesystem filled the out iov in place; count + // the bytes without copying. + w.skip(len(req.outPayload)) + } else { + w.write(req.outPayload) + } + return w.written, OK +} + +func readIntoIov(rr ReadResult, dst [][]byte) (int, Status) { + switch r := rr.(type) { + case withReadv: + return r.Readv(dst) + case withSlice: + slices, code := r.Slices() + if !code.Ok() { + return 0, code + } + return copyToIov(dst, slices...), OK + } + return 0, EIO +} + +func iovLens(in [][]byte) []int { + var lens []int + for _, b := range in { + lens = append(lens, len(b)) + } + return lens +} diff --git a/third_party/go-fuse/fuse/read.go b/third_party/go-fuse/fuse/read.go new file mode 100644 index 000000000..2f99df75d --- /dev/null +++ b/third_party/go-fuse/fuse/read.go @@ -0,0 +1,142 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "io" + "syscall" +) + +// ReadResultData is the read return for returning bytes directly. +type readResultData struct { + // Raw bytes for the read. + Data []byte +} + +func (r *readResultData) Size() int { + return len(r.Data) +} + +func (r *readResultData) Done() { +} + +func (r *readResultData) Bytes(buf []byte) ([]byte, Status) { + return r.Data, OK +} + +func (r *readResultData) Readv(dst [][]byte) (int, Status) { + return copyToIov(dst, r.Data), OK +} + +func ReadResultData(b []byte) ReadResult { + return &readResultData{b} +} + +func ReadResultFd(fd uintptr, off int64, sz int) ReadResult { + return &readResultFd{fd, off, sz} +} + +type seekableResult interface { + Seekable() (fd uintptr, off int64, sz int) +} + +type statefulResult interface { + Stateful() (fd uintptr, sz int) +} + +type withSlice interface { + // Slices may be called more than once and must return the same data each time. + Slices() ([][]byte, Status) +} + +// withReadv is a ReadResult that can write directly into a scatter +// list, e.g. virtiofs guest memory. +type withReadv interface { + Readv(dst [][]byte) (int, Status) +} + +// withSpliceFlags is a ReadResult carrying splice(2) flags for the write to +// /dev/fuse. +type withSpliceFlags interface { + SpliceFlags() int +} + +// ReadResultFd is the read return for zero-copy file data. +type readResultFd struct { + // Splice from the following file. + Fd uintptr + + // Offset within Fd, or -1 to use current offset. + Off int64 + + // Size of data to be loaded. Actual data available may be + // less at the EOF. + Sz int +} + +func (r *readResultFd) Seekable() (fd uintptr, off int64, sz int) { + return r.Fd, r.Off, r.Sz +} + +// Reads raw bytes from file descriptor if necessary, using the passed +// buffer as storage. +func (r *readResultFd) Bytes(buf []byte) ([]byte, Status) { + sz := min(len(buf), r.Sz) + + n, err := syscall.Pread(int(r.Fd), buf[:sz], r.Off) + if err == io.EOF { + err = nil + } + + if n < 0 { + n = 0 + } + + return buf[:n], ToStatus(err) +} + +func (r *readResultFd) Size() int { + return r.Sz +} + +func (r *readResultFd) Done() { +} + +// readResultVector is the read return for scatter-gather I/O. It implements +// the withSlice interface so the kernel write uses writev(2) directly, +// avoiding a copy into a single contiguous buffer. +type readResultVector struct { + vecs [][]byte +} + +func (r *readResultVector) Size() int { + return iovLen(r.vecs) +} + +func (r *readResultVector) Done() {} + +// Bytes concatenates the vector into buf, reusing its capacity. +func (r *readResultVector) Bytes(buf []byte) ([]byte, Status) { + buf = buf[:0] + for _, v := range r.vecs { + buf = append(buf, v...) + } + return buf, OK +} + +func (r *readResultVector) Readv(dst [][]byte) (int, Status) { + return copyToIov(dst, r.vecs...), OK +} + +func (r *readResultVector) Slices() ([][]byte, Status) { + return r.vecs, OK +} + +// ReadResultVector returns a ReadResult for scatter-gather I/O. +// When the kernel write path supports it, the buffers are sent via +// writev(2) without being copied into a single contiguous region. +func ReadResultVector(vecs [][]byte) ReadResult { + return &readResultVector{vecs} +} diff --git a/third_party/go-fuse/fuse/read_linux.go b/third_party/go-fuse/fuse/read_linux.go new file mode 100644 index 000000000..de95b98a5 --- /dev/null +++ b/third_party/go-fuse/fuse/read_linux.go @@ -0,0 +1,22 @@ +// Copyright 2026 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "golang.org/x/sys/unix" + +func (r *readResultFd) Readv(dst [][]byte) (int, Status) { + dst = sliceIov(dst, 0, r.Sz) + var n int + var err error + if r.Off == -1 { + n, err = unix.Readv(int(r.Fd), dst) + } else { + n, err = unix.Preadv(int(r.Fd), dst, r.Off) + } + if n < 0 { + n = 0 + } + return n, ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/request.go b/third_party/go-fuse/fuse/request.go new file mode 100644 index 000000000..1037d5f7c --- /dev/null +++ b/third_party/go-fuse/fuse/request.go @@ -0,0 +1,390 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "log" + "reflect" + "strings" + "time" + "unsafe" +) + +const sizeOfOutHeader = unsafe.Sizeof(OutHeader{}) + +type request struct { + inflightIndex int + + cancel chan struct{} + + suppressReply bool + + // written under Server.interruptMu + interrupted bool + + // inHeader + opcode specific data + inputBuf []byte + + // Output header (OutHeader). + outHeaderBuf []byte + + // Opcode-specific structured output data. + outDataBuf []byte + + // Unstructured input (filenames, data for WRITE call) + inPayload []byte + + // WRITE data, if not contiguous. + inPayloadIov [][]byte + inPayloadOne [1][]byte + + // nil if the request has no extension. + ext *requestExt + + // Output data. + status Status + + // Unstructured output. Only one of outPayload and readResult is non-nil. + outPayload []byte + readResult ReadResult + + // Start timestamp for timing info. + startTime time.Time +} + +// requestAlloc holds the request, plus I/O buffers, which are +// reused across requests. +type requestAlloc struct { + request + + // Request storage. For large inputs and outputs, use data + // obtained through bufferpool. + bufferPoolInputBuf []byte + bufferPoolOutputBuf []byte + + // For small pieces of data, we use the following inline arrays: + + // Fixed-size output header storage. + outHeaderInline [unsafe.Sizeof(OutHeader{})]byte + + // Opcode-specific structured output data storage. + outDataInline [uintptr(outputDataSize)]byte + + // Input, if small enough to fit here. + smallInputBuf [128]byte + + extInline requestExt +} + +func (r *request) inHeader() *InHeader { + return (*InHeader)(r.inData()) +} + +func (r *request) outHeader() *OutHeader { + return (*OutHeader)(unsafe.Pointer(&r.outHeaderBuf[0])) +} + +// TODO - benchmark to see if this is necessary? +func (r *request) clear() { + r.suppressReply = false + r.inputBuf = nil + r.outHeaderBuf = nil + r.outDataBuf = nil + r.inPayload = nil + r.inPayloadIov = nil + r.inPayloadOne[0] = nil + r.ext = nil + r.status = OK + r.outPayload = nil + r.startTime = time.Time{} + r.readResult = nil +} + +func asType(ptr unsafe.Pointer, typ any) any { + return reflect.NewAt(reflect.ValueOf(typ).Type(), ptr).Interface() +} + +func typSize(typ any) uintptr { + return reflect.ValueOf(typ).Type().Size() +} + +func (r *request) InputDebug() string { + val := "" + + hdr := r.inHeader() + h := getHandler(hdr.Opcode) + if h != nil && h.InType != nil { + val = Print(asType(r.inData(), h.InType)) + } + + names := "" + if h.FileNames == 1 { + name := r.filename() + + rest := r.inPayload[min(len(name)+1, len(r.inPayload)):] + names = fmt.Sprintf(" %q %s", name, summarizePayload(rest)) + } else if h.FileNames == 2 { + n1, n2, _ := r.filenames() + names = fmt.Sprintf(" %q %q", n1, n2) + } else if r.inPayloadIov != nil { + names = fmt.Sprintf(" %db in %d segments", iovLen(r.inPayloadIov), len(r.inPayloadIov)) + } else { + names = summarizePayload(r.inPayload) + } + + ext := "" + if r.ext != nil { + ext = " " + r.ext.String() + } + + return fmt.Sprintf("rx %d: %s n%d %s%s%s p%d", + hdr.Unique, operationName(hdr.Opcode), hdr.NodeId, + val, names, ext, hdr.Caller.Pid) +} + +func summarizePayload(p []byte) string { + l := len(p) + if l > 0 { + dots := "" + if l > 8 { + l = 8 + dots = "..." + } + + return fmt.Sprintf("%q%s %db", p[:l], dots, len(p)) + } + return "" +} + +func (r *request) OutputDebug() string { + var dataStr string + h := getHandler(r.inHeader().Opcode) + if h != nil && h.OutType != nil && len(r.outDataBuf) > 0 { + dataStr = Print(asType(r.outData(), h.OutType)) + } + + max := 1024 + if len(dataStr) > max { + dataStr = dataStr[:max] + " ...trimmed" + } + + flatStr := "" + if r.outPayloadSize() > 0 { + if h != nil && h.FileNameOut { + s := strings.TrimRight(string(r.outPayload), "\x00") + flatStr = fmt.Sprintf(" %q", s) + } else { + spl := "" + + if ws, ok := r.readResult.(withSlice); ok { + slices, _ := ws.Slices() + spl = fmt.Sprintf(" (%d slices)", len(slices)) + } else if r.readResult != nil { + _, pipeOK := r.readResult.(statefulResult) + _, fdOK := r.readResult.(seekableResult) + if fdOK || pipeOK { + spl = fmt.Sprintf(" (fd %db data)", r.readResult.Size()) + } + } else { + l := len(r.outPayload) + s := "" + if l > 8 { + l = 8 + s = "..." + } + spl = fmt.Sprintf(" %q%s", r.outPayload[:l], s) + } + flatStr = fmt.Sprintf(" %db data%s", r.outPayloadSize(), spl) + } + } + + extraStr := dataStr + flatStr + if extraStr != "" { + extraStr = ", " + extraStr + } + return fmt.Sprintf("tx %d: %v%s", + r.inHeader().Unique, r.status, extraStr) +} + +// setInput returns true if it takes ownership of the argument, false if not. +func (r *requestAlloc) setInput(input []byte) bool { + if len(input) < len(r.smallInputBuf) { + copy(r.smallInputBuf[:], input) + r.inputBuf = r.smallInputBuf[:len(input)] + return false + } + r.inputBuf = input + r.bufferPoolInputBuf = input[:cap(input)] + + return true +} + +func (r *requestAlloc) splitPayload(inSize, structSize int) Status { + r.inPayload = r.inputBuf[inSize:] + r.inputBuf = r.inputBuf[:inSize] + r.extendInput(structSize) + return r.splitExt(&r.extInline) +} + +func (r *requestAlloc) extendInput(size int) { + n := len(r.inputBuf) + if n >= size { + return + } + payloadInline := len(r.inPayload) > 0 && unsafe.SliceData(r.inPayload) == &r.smallInputBuf[n] + if payloadInline && size+len(r.inPayload) > len(r.smallInputBuf) { + buf := make([]byte, size) + copy(buf, r.inputBuf) + r.inputBuf = buf + return + } + if payloadInline { + r.inPayload = r.smallInputBuf[size : size+copy(r.smallInputBuf[size:], r.inPayload)] + } else if unsafe.SliceData(r.inputBuf) != &r.smallInputBuf[0] { + copy(r.smallInputBuf[:], r.inputBuf) + } + clear(r.smallInputBuf[n:size]) + r.inputBuf = r.smallInputBuf[:size] +} + +// splitExt parses the extension, if any, into storage. +func (r *request) splitExt(storage *requestExt) Status { + extLen := int(r.inHeader().TotalExtlen) * 8 + if extLen == 0 { + return OK + } + if extLen > len(r.inPayload) { + return EIO + } + split := len(r.inPayload) - extLen + b := r.inPayload[split:] + r.inPayload = r.inPayload[:split] + r.ext = storage + return storage.parse(b) +} + +func (r *request) inData() unsafe.Pointer { + return unsafe.Pointer(&r.inputBuf[0]) +} + +// note: outSize is without OutHeader +func parseRequest(in []byte, kernelSettings *InitIn, negotiated uint64) (h *operationHandler, inSize, outSize, outPayloadSize int, errno Status) { + inSize = int(unsafe.Sizeof(InHeader{})) + if len(in) < inSize { + errno = EIO + return + } + inData := unsafe.Pointer(&in[0]) + hdr := (*InHeader)(inData) + h = getHandler(hdr.Opcode) + if h == nil { + errno = ENOSYS + return + } + if h.InputSize > 0 { + inSize = int(h.InputSize) + } + if hdr.Opcode == _OP_RENAME { + inSize = renameInSize(kernelSettings) + } + if hdr.Opcode == _OP_SETXATTR { + inSize = setXAttrInSize(negotiated) + } + if hdr.Opcode == _OP_INIT && inSize > len(in) { + // Minor version 36 extended the size of InitIn struct + inSize = len(in) + } + if len(in) < inSize { + log.Printf("Short read for %v: %q", h.Name, in) + errno = EIO + return + } + + outSize = int(h.OutputSize) + + switch hdr.Opcode { + case _OP_READDIR, _OP_READDIRPLUS, _OP_READ: + outPayloadSize = int(((*ReadIn)(inData)).Size) + case _OP_GETXATTR, _OP_LISTXATTR: + // [GET|LIST]XATTR is two opcodes in one: get/list xattr size (return + // structured GetXAttrOut, no flat data) and get/list xattr data + // (return no structured data, but only flat data) + outPayloadSize = int(((*GetXAttrIn)(inData)).Size) + if outPayloadSize > 0 { + outSize = 0 + } + case _OP_IOCTL: + outPayloadSize = int(((*IoctlIn)(inData)).OutSize) + } + + return +} + +func (r *request) outData() unsafe.Pointer { + return unsafe.Pointer(&r.outDataBuf[0]) +} + +func (r *request) filename() string { + idx := bytes.IndexByte(r.inPayload, 0) + + name := r.inPayload + if idx >= 0 { + name = name[:idx] + } + return string(name) +} + +func (r *request) filenames() (string, string, Status) { + i1 := bytes.IndexByte(r.inPayload, 0) + if i1 < 0 { + return "", "", EIO + } + rest := r.inPayload[i1+1:] + i2 := bytes.IndexByte(rest, 0) + if i2 < 0 { + return string(r.inPayload[:i1]), "", EIO + } + return string(r.inPayload[:i1]), string(rest[:i2]), OK +} + +// serializeHeader serializes the response header. The header points +// to an internal buffer of the receiver. +func (r *request) serializeHeader(outPayloadSize int) { + if r.status > OK { + // only do this for positive status; negative status + // is used for notification. + r.outDataBuf = nil + outPayloadSize = 0 + } + + // The InitOut structure has 24 bytes (ie. TimeGran and + // further fields not available) in fuse version <= 22. + // https://john-millikin.com/the-fuse-protocol#FUSE_INIT + if r.status.Ok() && r.inHeader().Opcode == _OP_INIT { + out := (*InitOut)(r.outData()) + if out.Minor <= 22 { + r.outDataBuf = r.outDataBuf[:24] + } + } + + o := r.outHeader() + o.Unique = r.inHeader().Unique + o.Status = int32(-r.status) + o.Length = uint32( + int(sizeOfOutHeader) + len(r.outDataBuf) + outPayloadSize) + + if r.outPayload != nil { + r.outPayload = r.outPayload[:outPayloadSize] + } +} + +func (r *request) outPayloadSize() int { + if r.readResult != nil { + return r.readResult.Size() + } + return len(r.outPayload) +} diff --git a/third_party/go-fuse/fuse/request_darwin.go b/third_party/go-fuse/fuse/request_darwin.go new file mode 100644 index 000000000..7c96819d3 --- /dev/null +++ b/third_party/go-fuse/fuse/request_darwin.go @@ -0,0 +1,13 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +const outputDataSize = 184 + +const ( + _FUSE_KERNEL_VERSION = 7 + _MINIMUM_MINOR_VERSION = 12 + _OUR_MINOR_VERSION = 19 +) diff --git a/third_party/go-fuse/fuse/request_freebsd.go b/third_party/go-fuse/fuse/request_freebsd.go new file mode 100644 index 000000000..7b1a40135 --- /dev/null +++ b/third_party/go-fuse/fuse/request_freebsd.go @@ -0,0 +1,9 @@ +package fuse + +const outputDataSize = 144 + +const ( + _FUSE_KERNEL_VERSION = 7 + _MINIMUM_MINOR_VERSION = 12 + _OUR_MINOR_VERSION = 28 +) diff --git a/third_party/go-fuse/fuse/request_linux.go b/third_party/go-fuse/fuse/request_linux.go new file mode 100644 index 000000000..928a538c5 --- /dev/null +++ b/third_party/go-fuse/fuse/request_linux.go @@ -0,0 +1,13 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +const outputDataSize = 288 + +const ( + _FUSE_KERNEL_VERSION = 7 + _MINIMUM_MINOR_VERSION = 12 + _OUR_MINOR_VERSION = 28 +) diff --git a/third_party/go-fuse/fuse/server.go b/third_party/go-fuse/fuse/server.go new file mode 100644 index 000000000..658b8f261 --- /dev/null +++ b/third_party/go-fuse/fuse/server.go @@ -0,0 +1,875 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "errors" + "fmt" + "log" + "math" + "os" + "path" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" +) + +const ( + // Linux v4.20+ caps requests at 1 MiB. Older kernels at 128 + // kiB. Deprecated: current linux kernels allow tuning this + // using sysctl. + MAX_KERNEL_WRITE = 1024 * 1024 + + // Linux kernel constant from include/uapi/linux/fuse.h + // Reads from /dev/fuse that are smaller fail with EINVAL. + _FUSE_MIN_READ_BUFFER = 8192 + + // defaultMaxWrite is the default value for MountOptions.MaxWrite + defaultMaxWrite = 128 * 1024 // 128 kiB + + minMaxReaders = 2 + maxMaxReaders = 16 +) + +// Server contains the logic for reading from the FUSE device. +type Server struct { + protocolServer + + // Empty if unmounted. + mountPoint string + + fuseFD *fuseFD + + opts *MountOptions + + // maxReaders is the maximum number of goroutines reading requests + maxReaders int + + // Pool for request structs. + reqPool sync.Pool + + // Pool for raw requests data + readPool sync.Pool + + singleReader bool + canSplice bool + serving atomic.Bool // for preventing duplicate Serve() calls + + // Used to implement WaitMount on macos. + ready chan error + + // for implementing single threaded processing. + requestProcessingMu sync.Mutex +} + +// SetDebug is deprecated. Use MountOptions.Debug instead. +func (ms *Server) SetDebug(dbg bool) { + // This will typically trigger the race detector. + ms.opts.Debug = dbg +} + +// KernelSettings returns the Init message from the kernel, so +// filesystems can adapt to availability of features of the kernel +// driver. The message should not be altered. +func (ms *Server) KernelSettings() *InitIn { + s := ms.kernelSettings + + return &s +} + +const _MAX_NAME_LEN = 20 + +// This type is deprecated. +type LatencyMap interface { + Add(name string, dt time.Duration) +} + +// RecordLatencies is deprecated. It is provided for backward compatibility but does not do anything. +func (ms *Server) RecordLatencies(l LatencyMap) { +} + +// Unmount calls fusermount -u on the mount. This has the effect of +// shutting down the filesystem. After the Server is unmounted, it +// should be discarded. This function is idempotent. +// +// Does not work when we were mounted with the magic /dev/fd/N mountpoint syntax, +// as we do not know the real mountpoint. Unmount using +// +// fusermount -u /path/to/real/mountpoint +// +// in this case. +func (ms *Server) Unmount() (err error) { + if ms.mountPoint == "" { + return nil + } + if parseFuseFd(ms.mountPoint) >= 0 { + return fmt.Errorf("Cannot unmount magic mountpoint %q. Please use `fusermount -u REALMOUNTPOINT` instead.", ms.mountPoint) + } + delay := time.Duration(0) + for try := 0; try < 5; try++ { + err = unmount(ms.mountPoint, ms.opts) + if err == nil { + break + } + + // Sleep for a bit. This is not pretty, but there is + // no way we can be certain that the kernel thinks all + // open files have already been closed. + delay = 2*delay + 5*time.Millisecond + time.Sleep(delay) + } + if err != nil { + return + } + // Wait for event loops to exit. + ms.fuseFD.loops.Wait() + ms.mountPoint = "" + return err +} + +// alignSlice ensures that the byte at alignedByte is aligned with the +// given logical block size. The input slice should be at least (size +// + blockSize) +func alignSlice(buf []byte, alignedByte, blockSize, size uintptr) []byte { + misaligned := uintptr(unsafe.Pointer(&buf[alignedByte])) & (blockSize - 1) + buf = buf[blockSize-misaligned:] + return buf[:size] +} + +func (o *MountOptions) setDefaults(fs RawFileSystem) { + if o.Logger == nil { + o.Logger = log.Default() + } + if o.MaxWrite < 0 { + o.MaxWrite = 0 + } + if o.MaxWrite == 0 { + o.MaxWrite = defaultMaxWrite + } + kernelMaxWrite := getMaxWrite() + if o.MaxWrite > kernelMaxWrite { + o.MaxWrite = kernelMaxWrite + } + if o.MaxInflightRequestBytes <= 0 { + o.MaxInflightRequestBytes = math.MaxInt + } + if o.MaxStackDepth == 0 { + o.MaxStackDepth = 1 + } + if o.Name == "" { + name := fs.String() + l := min(len(name), _MAX_NAME_LEN) + o.Name = strings.Replace(name[:l], ",", ";", -1) + } + if o.PanicHandler == nil { + l := o.Logger + o.PanicHandler = func(obj any) Status { + return defaultPanicHandler(l, obj) + } + } + + for _, s := range []struct { + flag bool + mask uint64 + }{ + {o.SyncRead, CAP_ASYNC_READ}, + {o.DisableReadDirPlus, CAP_READDIRPLUS}, + {!o.IDMappedMount, CAP_ALLOW_IDMAP}, + } { + if s.flag { + o.DisabledCapabilities |= s.mask + } + } +} + +func defaultPanicHandler(logger *log.Logger, obj any) Status { + const size = 64 << 10 + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + logger.Printf("panic in FS handler: %v\n%s", obj, buf) + return EIO +} + +// NewServer creates a FUSE server and attaches ("mounts") it to the +// `mountPoint` directory. +// +// See the "Mount styles" section in the package documentation if you want to +// know about the inner workings of the mount process. Usually you do not. +func NewServer(fs RawFileSystem, mountPoint string, opts *MountOptions) (*Server, error) { + if opts == nil { + opts = &MountOptions{ + MaxBackground: _DEFAULT_BACKGROUND_TASKS, + } + } + o := *opts + o.setDefaults(fs) + + maxReaders := runtime.GOMAXPROCS(0) + if maxReaders < minMaxReaders { + maxReaders = minMaxReaders + } else if maxReaders > maxMaxReaders { + maxReaders = maxMaxReaders + } + + readBufSize, readBufBytes, _ := requestAccountingSizes(o.MaxWrite) + + ms := &Server{ + protocolServer: protocolServer{ + fileSystem: fs, + retrieveTab: make(map[uint64]*retrieveCacheRequest), + opts: &o, + }, + opts: &o, + maxReaders: maxReaders, + singleReader: useSingleReader, + ready: make(chan error, 1), + } + ms.reqPool.New = func() any { + return &requestAlloc{ + request: request{ + cancel: make(chan struct{}), + }, + } + } + ms.readPool.New = func() any { + // O_DIRECT typically requires buffers aligned to + // blocksize (see man 2 open), but requirements vary + // across file systems. Presumably, we could also fix + // this by reading the requests using readv. + buf := make([]byte, readBufBytes) + buf = alignSlice(buf, unsafe.Sizeof(WriteIn{}), logicalBlockSize, uintptr(readBufSize)) + return buf + } + mountPoint = filepath.Clean(mountPoint) + if !filepath.IsAbs(mountPoint) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + mountPoint = filepath.Clean(filepath.Join(cwd, mountPoint)) + } + fd, err := mount(mountPoint, &o, ms.ready) + if err != nil { + return nil, err + } + + ms.mountPoint = mountPoint + ms.fuseFD, err = ms.newFuseFD(fd) + if err != nil { + return nil, err + } + ms.protocolServer.writev = ms.fuseFD.writev + + if code := ms.handleInit(); !code.Ok() { + ms.fuseFD.close() + // TODO - unmount as well? + return nil, fmt.Errorf("init: %s", code) + } + + // This prepares for Serve being called somewhere, either + // synchronously or asynchronously. + ms.fuseFD.loops.Add(1) + return ms, nil +} + +func requestAccountingSizes(maxWrite int) (readBufSize, readBufBytes, reqAllocBytes int) { + readBufSize = max(maxWrite+int(maxInputSize), _FUSE_MIN_READ_BUFFER) + readBufBytes = readBufSize + logicalBlockSize + reqAllocBytes = int(unsafe.Sizeof(requestAlloc{})) + return +} + +func escape(optionValue string) string { + return strings.Replace(strings.Replace(optionValue, `\`, `\\`, -1), `,`, `\,`, -1) +} + +func (o *MountOptions) optionsStrings() []string { + var r []string + r = append(r, o.Options...) + + if o.AllowOther { + r = append(r, "allow_other") + } + if o.FsName != "" { + r = append(r, "fsname="+o.FsName) + } + if o.Name != "" { + r = append(r, "subtype="+o.Name) + } + r = append(r, fmt.Sprintf("max_read=%d", o.MaxWrite)) + + // OSXFUSE applies a 60-second timeout for file operations. This + // is inconsistent with how FUSE works on Linux, where operations + // last as long as the daemon is willing to let them run. + if runtime.GOOS == "darwin" { + r = append(r, "daemon_timeout=0") + } + if o.IDMappedMount && !o.containsOption("default_permissions") { + r = append(r, "default_permissions") + } + + // Commas and backslashs in an option need to be escaped, because + // options are separated by a comma and backslashs are used to + // escape other characters. + var rEscaped []string + for _, s := range r { + rEscaped = append(rEscaped, escape(s)) + } + + return rEscaped +} + +func (o *MountOptions) containsOption(opt string) bool { + return slices.Contains(o.Options, opt) +} + +// DebugData returns internal status information for debugging +// purposes. +func (ms *Server) DebugData() string { + var r int + ms.fuseFD.reqMu.Lock() + r = ms.fuseFD.reqReaders + ms.fuseFD.reqMu.Unlock() + + return fmt.Sprintf("readers: %d", r) +} + +// handleEINTR retries the given function until it doesn't return syscall.EINTR. +// This is similar to the HANDLE_EINTR() macro from Chromium ( see +// https://code.google.com/p/chromium/codesearch#chromium/src/base/posix/eintr_wrapper.h +// ) and the TEMP_FAILURE_RETRY() from glibc (see +// https://www.gnu.org/software/libc/manual/html_node/Interrupted-Primitives.html +// ). +// +// Don't use handleEINTR() with syscall.Close(); see +// https://code.google.com/p/chromium/issues/detail?id=269623 . +func handleEINTR(fn func() error) (err error) { + for { + err = fn() + if err != syscall.EINTR { + break + } + } + return +} + +// Serve initiates the FUSE loop. Normally, callers should run Serve() +// and wait for it to exit, but tests will want to run this in a +// goroutine. +// +// Each filesystem operation executes in a separate goroutine. +func (ms *Server) Serve() { + if !ms.serving.CompareAndSwap(false, true) { + // Calling Serve() multiple times leads to a panic on unmount and fun + // debugging sessions ( https://github.com/hanwen/go-fuse/issues/512 ). + // Catch it early. + log.Panic("Serve() must only be called once, you have called it a second time") + } + + ms.loop() + ms.fuseFD.loops.Wait() + + ms.fuseFD.close() + + // shutdown in-flight cache retrieves. + // + // It is possible that umount comes in the middle - after retrieve + // request was sent to kernel, but corresponding kernel reply has not + // yet been read. We unblock all such readers and wake them up with ENODEV. + ms.retrieveMu.Lock() + rtab := ms.retrieveTab + // retrieve attempts might be erroneously tried even after close + // we have to keep retrieveTab !nil not to panic. + ms.retrieveTab = make(map[uint64]*retrieveCacheRequest) + ms.retrieveMu.Unlock() + for _, reading := range rtab { + reading.n = 0 + reading.st = ENODEV + close(reading.ready) + } + + ms.fileSystem.OnUnmount() +} + +// Wait waits for the serve loop to exit. This should only be called +// after Serve has been called, or it will hang indefinitely. +func (ms *Server) Wait() { + ms.fuseFD.loops.Wait() +} + +func (ms *Server) handleInit() Status { + // The first request should be INIT; read it synchronously, + // and don't spawn new readers. + orig := ms.singleReader + ms.singleReader = true + req, errNo := ms.fuseFD.readRequest() + ms.singleReader = orig + + if errNo != OK || req == nil { + return errNo + } + if code := ms.handleRequest(req); !code.Ok() { + return code + } + + if ms.kernelSettings.Minor >= 13 { + ms.setSplice() + } + + // INIT is handled. Init the file system, but don't accept + // incoming requests, so the file system can setup itself. + ms.fileSystem.Init(ms) + return OK +} + +// loop is the FUSE event loop. The simplistic way of calling this is +// with singleReader=true, which has a single goroutine reading the +// device, and spawning a new goroutine for each request. It is +// however 2x slower than processing the request inline with the +// reader. The latter requires more logic, because whenever we start +// processing the request, we have to make sure a new routine is +// spawned to read the device. +// +// Benchmark results i5-8350 pinned at 2Ghz: +// +// singleReader = true +// +// BenchmarkGoFuseRead 954 1137408 ns/op 1843.80 MB/s 5459 B/op 173 allocs/op +// BenchmarkGoFuseRead-2 1327 798635 ns/op 2625.92 MB/s 5072 B/op 169 allocs/op +// BenchmarkGoFuseStat 1530 750944 ns/op +// BenchmarkGoFuseStat-2 8455 120091 ns/op +// BenchmarkGoFuseReaddir 741 1561004 ns/op +// BenchmarkGoFuseReaddir-2 2508 599821 ns/op +// +// singleReader = false +// +// BenchmarkGoFuseRead 1890 671576 ns/op 3122.73 MB/s 5393 B/op 136 allocs/op +// BenchmarkGoFuseRead-2 2948 429578 ns/op 4881.88 MB/s 32235 B/op 157 allocs/op +// BenchmarkGoFuseStat 7886 153546 ns/op +// BenchmarkGoFuseStat-2 9310 121332 ns/op +// BenchmarkGoFuseReaddir 4074 361568 ns/op +// BenchmarkGoFuseReaddir-2 3511 319765 ns/op +func (ms *Server) loop() { + defer ms.fuseFD.loops.Done() +exit: + for { + req, errNo := ms.fuseFD.readRequest() + switch errNo { + case OK: + if req == nil { + break exit + } + case ENOENT: + continue + case ENODEV: + // Mount was killed. The obvious place to + // cancel outstanding requests is at the end + // of Serve, but that reader might be blocked. + ms.cancelAll() + if ms.opts.Debug { + ms.opts.Logger.Printf("received ENODEV (unmount request), thread exiting") + } + break exit + default: // some other error? + ms.opts.Logger.Printf("Failed to read from fuse conn: %v", errNo) + break exit + } + + if ms.singleReader && ms.fuseFD.canAcceptAnother() { + go ms.handleRequest(req) + continue + } + ms.handleRequest(req) + } +} + +func (ms *Server) handleRequest(req *requestAlloc) Status { + defer ms.fuseFD.returnRequest(req) + if ms.opts.SingleThreaded { + ms.requestProcessingMu.Lock() + defer ms.requestProcessingMu.Unlock() + } + + h, inSize, outSize, outPayloadSize, code := parseRequest(req.inputBuf, &ms.kernelSettings, ms.negotiatedFlags) + req.request.status = code + if !code.Ok() && code != ENOSYS { + ms.opts.Logger.Printf("parseRequest: %v", code) + } + if code.Ok() { + req.request.status = req.splitPayload(inSize, int(h.InputSize)) + if !req.request.status.Ok() { + ms.opts.Logger.Printf("op %s: bad request extension: %v", h.Name, req.request.status) + } + } + req.outHeaderBuf = req.outHeaderInline[:] + req.outDataBuf = req.outDataInline[:outSize] + clear(req.outHeaderBuf) + clear(req.outDataBuf) + if outPayloadSize > 0 { + req.outPayload = ms.buffers.AllocBuffer(uint32(outPayloadSize)) + req.bufferPoolOutputBuf = req.outPayload + } + ms.protocolServer.handleRequest(h, &req.request) + if req.suppressReply { + if observer, ok := ms.fileSystem.(ReplySuppressedObserver); ok { + observer.OnReplySuppressed(req.inHeader().Unique, req.inHeader().Opcode, req.status) + } + return OK + } + errno := ms.fuseFD.write(&req.request) + if observer, ok := ms.fileSystem.(ReplyAttemptObserver); ok { + observer.OnReplyAttempt(req.inHeader().Unique, req.inHeader().Opcode, req.status, errno) + } + if errno != 0 { + // Ignore ENOENT for INTERRUPT responses which + // indicates that the referred request is no longer + // known by the kernel. This is a normal if the + // referred request already has completed. + // + // Ignore ENOENT for RELEASE responses. When the FS + // is unmounted directly after a file close, the + // device can go away while we are still processing + // RELEASE. This is because RELEASE is analogous to + // FORGET, and is not synchronized with the calling + // process, but does require a response. + if ms.opts.Debug || !(errno == ENOENT && (req.inHeader().Opcode == _OP_INTERRUPT || + req.inHeader().Opcode == _OP_RELEASEDIR || + req.inHeader().Opcode == _OP_RELEASE)) { + ms.opts.Logger.Printf("writer: Write/Writev failed, err: %v. opcode: %v", + errno, operationName(req.inHeader().Opcode)) + } + } + return errno +} + +func notifyWrite(writev func([][]byte) (int, syscall.Errno), opts *MountOptions, req *request) Status { + req.serializeHeader(req.outPayloadSize()) + + if opts.Debug { + opts.Logger.Println(req.OutputDebug()) + } + + _, errno := writev([][]byte{req.outHeaderBuf, req.outDataBuf, req.outPayload}) + + if opts.Debug { + h := getHandler(req.inHeader().Opcode) + opts.Logger.Printf("Response %s: %v", h.Name, errno) + } + + return Status(errno) +} + +func (ms *protocolServer) notifyWrite(req *request) Status { + if ms.writev == nil { + return ENOSYS + } + errno := notifyWrite(ms.writev, ms.opts, req) + return Status(errno) +} + +func newNotifyRequest(opcode uint32) *request { + r := &request{ + inputBuf: make([]byte, unsafe.Sizeof(InHeader{})), + outHeaderBuf: make([]byte, sizeOfOutHeader), + outDataBuf: make([]byte, getHandler(opcode).OutputSize), + status: map[uint32]Status{ + _OP_NOTIFY_INVAL_INODE: NOTIFY_INVAL_INODE, + _OP_NOTIFY_INVAL_ENTRY: NOTIFY_INVAL_ENTRY, + _OP_NOTIFY_STORE_CACHE: NOTIFY_STORE_CACHE, + _OP_NOTIFY_RETRIEVE_CACHE: NOTIFY_RETRIEVE_CACHE, + _OP_NOTIFY_DELETE: NOTIFY_DELETE, + _OP_NOTIFY_PRUNE: NOTIFY_PRUNE, + }[opcode], + } + r.inHeader().Opcode = opcode + return r +} + +// InodeNotify invalidates the information associated with the inode +// (ie. data cache, attributes, etc.) +func (ms *protocolServer) InodeNotify(node uint64, off int64, length int64) Status { + req := newNotifyRequest(_OP_NOTIFY_INVAL_INODE) + + entry := (*NotifyInvalInodeOut)(req.outData()) + entry.Ino = node + entry.Off = off + entry.Length = length + + return ms.notifyWrite(req) +} + +func (ms *Server) PruneNotify(nodes []uint64) Status { + if !ms.kernelSettings.SupportsNotify(NOTIFY_PRUNE) { + return ENOSYS + } + if len(nodes) == 0 { + return OK + } + + req := newNotifyRequest(_OP_NOTIFY_PRUNE) + + entry := (*NotifyPruneOut)(req.outData()) + entry.Count = uint32(len(nodes)) + + req.outPayload = unsafe.Slice((*byte)(unsafe.Pointer(&nodes[0])), 8*len(nodes)) + + return ms.notifyWrite(req) +} + +// InodeNotifyStoreCache tells kernel to store data into inode's cache. +// +// This call is similar to InodeNotify, but instead of only invalidating a data +// region, it gives updated data directly to the kernel. +func (ms *protocolServer) InodeNotifyStoreCache(node uint64, offset int64, data []byte) Status { + for len(data) > 0 { + size := min(len(data), + // NotifyStoreOut has only uint32 for size. + // we check for max(int32), not max(uint32), because on 32-bit + // platforms int has only 31-bit for positive range. + math.MaxInt32) + + st := ms.inodeNotifyStoreCache32(node, offset, data[:size]) + if st != OK { + return st + } + + data = data[size:] + offset += int64(size) + } + + return OK +} + +// inodeNotifyStoreCache32 is internal worker for InodeNotifyStoreCache which +// handles data chunks not larger than 2GB. +func (ms *protocolServer) inodeNotifyStoreCache32(node uint64, offset int64, data []byte) Status { + req := newNotifyRequest(_OP_NOTIFY_STORE_CACHE) + + store := (*NotifyStoreOut)(req.outData()) + store.Nodeid = node + store.Offset = uint64(offset) // NOTE not int64, as it is e.g. in NotifyInvalInodeOut + store.Size = uint32(len(data)) + + req.outPayload = data + + return ms.notifyWrite(req) +} + +// InodeRetrieveCache retrieves data from kernel's inode cache. +// +// InodeRetrieveCache asks kernel to return data from its cache for inode at +// [offset:offset+len(dest)) and waits for corresponding reply. If kernel cache +// has fewer consecutive data starting at offset, that fewer amount is returned. +// In particular if inode data at offset is not cached (0, OK) is returned. +// +// The kernel returns ENOENT if it does not currently have entry for this inode +// in its dentry cache. +func (ms *protocolServer) InodeRetrieveCache(node uint64, offset int64, dest []byte) (n int, st Status) { + // the kernel won't send us in one go more then what we negotiated as MaxWrite. + // retrieve the data in chunks. + // TODO spawn some number of readahead retrievers in parallel. + ntotal := 0 + for { + chunkSize := min(len(dest), ms.opts.MaxWrite) + n, st = ms.inodeRetrieveCache1(node, offset, dest[:chunkSize]) + if st != OK || n == 0 { + break + } + + ntotal += n + offset += int64(n) + dest = dest[n:] + } + + // if we could retrieve at least something - it is ok. + // if ntotal=0 - st will be st returned from first inodeRetrieveCache1. + if ntotal > 0 { + st = OK + } + return ntotal, st +} + +// inodeRetrieveCache1 is internal worker for InodeRetrieveCache which +// actually talks to kernel and retrieves chunks not larger than ms.opts.MaxWrite. +func (ms *protocolServer) inodeRetrieveCache1(node uint64, offset int64, dest []byte) (n int, st Status) { + req := newNotifyRequest(_OP_NOTIFY_RETRIEVE_CACHE) + + // retrieve up to 2GB not to overflow uint32 size in NotifyRetrieveOut. + // see InodeNotifyStoreCache in similar place for why it is only 2GB, not 4GB. + // + // ( InodeRetrieveCache calls us with chunks not larger than + // ms.opts.MaxWrite, but MaxWrite is int, so let's be extra cautious ) + size := min(len(dest), math.MaxInt32) + dest = dest[:size] + + q := (*NotifyRetrieveOut)(req.outData()) + q.Nodeid = node + q.Offset = uint64(offset) // not int64, as it is e.g. in NotifyInvalInodeOut + q.Size = uint32(len(dest)) + + reading := &retrieveCacheRequest{ + nodeid: q.Nodeid, + offset: q.Offset, + dest: dest, + ready: make(chan struct{}), + } + + ms.retrieveMu.Lock() + q.NotifyUnique = ms.retrieveNext + ms.retrieveNext++ + ms.retrieveTab[q.NotifyUnique] = reading + ms.retrieveMu.Unlock() + + // Protect against concurrent close. + result := ms.notifyWrite(req) + if result != OK { + ms.retrieveMu.Lock() + r := ms.retrieveTab[q.NotifyUnique] + if r == reading { + delete(ms.retrieveTab, q.NotifyUnique) + } else if r == nil { + // ok - might be dequeued by umount + } else { + // although very unlikely, it is possible that kernel sends + // unexpected NotifyReply with our notifyUnique, then + // retrieveNext wraps, makes full cycle, and another + // retrieve request is made with the same notifyUnique. + ms.opts.Logger.Printf("W: INODE_RETRIEVE_CACHE: request with notifyUnique=%d mutated", q.NotifyUnique) + } + ms.retrieveMu.Unlock() + return 0, result + } + + // NotifyRetrieveOut sent to the kernel successfully. Now the kernel + // have to return data in a separate write-style NotifyReply request. + // Wait for the result. + <-reading.ready + return reading.n, reading.st +} + +// retrieveCacheRequest represents in-flight cache retrieve request. +type retrieveCacheRequest struct { + nodeid uint64 + offset uint64 + dest []byte + + // reply status + n int + st Status + ready chan struct{} +} + +// DeleteNotify notifies the kernel that an entry is removed from a +// directory. In many cases, this is equivalent to EntryNotify, +// except when the directory is in use, eg. as working directory of +// some process. You should not hold any FUSE filesystem locks, as that +// can lead to deadlock. +func (ms *protocolServer) DeleteNotify(parent uint64, child uint64, name string) Status { + req := newNotifyRequest(_OP_NOTIFY_DELETE) + + entry := (*NotifyInvalDeleteOut)(req.outData()) + entry.Parent = parent + entry.Child = child + entry.NameLen = uint32(len(name)) + + // Many versions of FUSE generate stacktraces if the + // terminating null byte is missing. + nameBytes := make([]byte, len(name)+1) + copy(nameBytes, name) + nameBytes[len(nameBytes)-1] = '\000' + req.outPayload = nameBytes + + return ms.notifyWrite(req) +} + +// EntryNotify should be used if the existence status of an entry +// within a directory changes. You should not hold any FUSE filesystem +// locks, as that can lead to deadlock. +func (ms *protocolServer) EntryNotify(parent uint64, name string) Status { + req := newNotifyRequest(_OP_NOTIFY_INVAL_ENTRY) + entry := (*NotifyInvalEntryOut)(req.outData()) + entry.Parent = parent + entry.NameLen = uint32(len(name)) + + // Many versions of FUSE generate stacktraces if the + // terminating null byte is missing. + nameBytes := make([]byte, len(name)+1) + copy(nameBytes, name) + nameBytes[len(nameBytes)-1] = '\000' + req.outPayload = nameBytes + + return ms.notifyWrite(req) +} + +// SupportsVersion returns true if the kernel supports the given +// protocol version or newer. +func (in *InitIn) SupportsVersion(maj, min uint32) bool { + return in.Major > maj || (in.Major == maj && in.Minor >= min) +} + +// SupportsNotify returns whether a certain notification type is +// supported. Pass any of the NOTIFY_* types as argument. +func (in *InitIn) SupportsNotify(notifyType int) bool { + switch notifyType { + case NOTIFY_INVAL_ENTRY: + return in.SupportsVersion(7, 12) + case NOTIFY_INVAL_INODE: + return in.SupportsVersion(7, 12) + case NOTIFY_STORE_CACHE, NOTIFY_RETRIEVE_CACHE: + return in.SupportsVersion(7, 15) + case NOTIFY_DELETE: + return in.SupportsVersion(7, 18) + case NOTIFY_PRUNE: + return in.SupportsVersion(7, 45) + } + return false +} + +// supportsRenameSwap returns whether the kernel supports the +// renamex_np(2) syscall. This is only supported on OS X. +func (in *InitIn) supportsRenameSwap() bool { + return in.Flags&CAP_RENAME_SWAP != 0 +} + +// WaitMount waits for the first request to be served. Use this to +// avoid racing between accessing the (empty or not yet mounted) +// mountpoint, and the OS trying to setup the user-space mount. +func (ms *Server) WaitMount() error { + err := <-ms.ready + if err != nil { + return err + } + if parseFuseFd(ms.mountPoint) >= 0 { + // Magic `/dev/fd/N` mountpoint. We don't know the real mountpoint, so + // we cannot run the poll hack. + return nil + } + return pollHack(ms.mountPoint) +} + +// parseFuseFd checks if `mountPoint` is the special form /dev/fd/N (with N >= 0), +// and returns N in this case. Returns -1 otherwise. +func parseFuseFd(mountPoint string) (fd int) { + dir, file := path.Split(mountPoint) + if dir != "/dev/fd/" { + return -1 + } + fd, err := strconv.Atoi(file) + if err != nil || fd <= 0 { + return -1 + } + return fd +} + +// errRecoverSplice is returned by trySplice when the caller should +// fall back to to pread/read without logging. +var errRecoverSplice = errors.New("splice failed; must fallback") diff --git a/third_party/go-fuse/fuse/splice_darwin.go b/third_party/go-fuse/fuse/splice_darwin.go new file mode 100644 index 000000000..cbf2a1c46 --- /dev/null +++ b/third_party/go-fuse/fuse/splice_darwin.go @@ -0,0 +1,13 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +func (s *Server) setSplice() { + s.canSplice = false +} + +func (ms *Server) trySplice(header []byte, req *request, fdData *readResultFd) error { + return errRecoverSplice +} diff --git a/third_party/go-fuse/fuse/splice_freebsd.go b/third_party/go-fuse/fuse/splice_freebsd.go new file mode 100644 index 000000000..e15fe04cf --- /dev/null +++ b/third_party/go-fuse/fuse/splice_freebsd.go @@ -0,0 +1,9 @@ +package fuse + +func (s *Server) setSplice() { + s.canSplice = false +} + +func (ms *Server) trySplice(header []byte, req *request, fdData *readResultFd) error { + return errRecoverSplice +} diff --git a/third_party/go-fuse/fuse/splice_linux.go b/third_party/go-fuse/fuse/splice_linux.go new file mode 100644 index 000000000..bdc02f9df --- /dev/null +++ b/third_party/go-fuse/fuse/splice_linux.go @@ -0,0 +1,152 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" + "log" + "os" + "syscall" + + "github.com/hanwen/go-fuse/v2/splice" +) + +func (s *Server) setSplice() { + s.canSplice = splice.Resizable() && !s.opts.DisableSplice +} + +// trySplice: Zero-copy read from fdData.Fd into /dev/fuse +// +// Optimistic fast path: assumes fdData.Sz bytes are available (no short +// read). The caller has already serialized the reply header with that size. +// File data is copied into a pipe only once: +// +// 1. Write pre-serialized header into pipe --> pipe: [header] +// 2. Splice file data directly into pipe --> pipe: [header][payload] +// 3. Splice pipe into /dev/fuse +// +// If a short read occurs (payloadLen < fdData.Sz), the header in the pipe +// would carry the wrong total length, so we return an error and let the +// caller fall back to a Pread-based path. +func (r *fuseFD) trySplice(req *request, readResult ReadResult) error { + // The caller (handleRequest) already called req.serializeHeader with + // readResult.Size(), so req.outHeaderBuf is correct for the optimistic case. + total := len(req.outHeaderBuf) + len(req.outDataBuf) + readResult.Size() + + spliceFlags := 0 + if f, ok := readResult.(withSpliceFlags); ok { + spliceFlags = f.SpliceFlags() + } + + pair, err := splice.Get() + if err != nil { + return err + } + defer splice.Done(pair) + + // Grow pipe to header + payload + one extra page. + // Without the extra page the kernel will block once the pipe is almost full. + if err := pair.Grow(total + os.Getpagesize()); err != nil { + return err + } + + // Write header into pipe. + headerSz, err := pair.Writev([][]byte{req.outHeaderBuf, req.outDataBuf}) + if err != nil { + return err + } + if want := len(req.outHeaderBuf) + len(req.outDataBuf); headerSz != want { + return fmt.Errorf("short write into splice: wrote %d, want %d", headerSz, want) + } + + // Splice file data directly into pipe (single copy). + var payloadLen int + var fd uintptr + var sz int + var off int64 + if seekable, ok := readResult.(seekableResult); ok { + fd, off, sz = seekable.Seekable() + payloadLen, err = pair.LoadFromAt(fd, sz, off) + } else if stateful, ok := readResult.(statefulResult); ok { + fd, sz = stateful.Stateful() + payloadLen, err = pair.LoadFrom(fd, sz) + } else { + return errRecoverSplice + } + + if err != nil { + return err + } + + if payloadLen != sz { + // Short read at EOF: the header carries the wrong total length. + + // drain header + _, err := pair.Read(make([]byte, headerSz)) + if err != nil { + return fmt.Errorf("fallback drain: %w", err) + } + + if r.server.opts.Debug { + log.Printf("tx %d: OK fixup fd %db data", req.inHeader().Unique, payloadLen) + } + // New length. + req.serializeHeader(payloadLen) + + return r.trySplice(req, ReadResultPipeFlags(pair, payloadLen, spliceFlags)) + } + + // Write header + payload to /dev/fuse. + if cerr := r.withFD(func(fd int) { + _, err = pair.WriteToFlags(uintptr(fd), total, spliceFlags) + }); cerr != nil { + return cerr + } + return err +} + +type pipeReadResult struct { + pair *splice.Pair + size int + flags int +} + +func (r *pipeReadResult) Done() { + splice.Done(r.pair) + r.pair = nil +} + +func (r *pipeReadResult) Bytes(buf []byte) ([]byte, Status) { + n, err := r.pair.Read(buf) + if n == -1 && err == syscall.EAGAIN { + return nil, 0 + } + return buf[:n], ToStatus(err) +} + +func (r *pipeReadResult) Size() int { + return r.size +} + +func (r *pipeReadResult) Stateful() (fd uintptr, sz int) { + return r.pair.ReadFd(), r.size +} + +func (r *pipeReadResult) SpliceFlags() int { return r.flags } + +// ReadResultPipe returns a [ReadResult] of `size` bytes that was preloaded +// into the given pipe. The pipe is discarded with splice.Done() +// after the read completes. +func ReadResultPipe(pipe *splice.Pair, size int) ReadResult { + return &pipeReadResult{pair: pipe, size: size} +} + +// ReadResultPipeFlags is [ReadResultPipe] with splice(2) flags. Only +// SPLICE_F_MOVE has an effect on /dev/fuse: it moves each page out of the +// source file's page cache, after waiting on its writeback, so use it only for +// pages the filesystem can lose. Only readahead reads are eligible. +func ReadResultPipeFlags(pipe *splice.Pair, size, flags int) ReadResult { + return &pipeReadResult{pair: pipe, size: size, flags: flags} +} diff --git a/third_party/go-fuse/fuse/syscall_darwin.go b/third_party/go-fuse/fuse/syscall_darwin.go new file mode 100644 index 000000000..cbb9413f2 --- /dev/null +++ b/third_party/go-fuse/fuse/syscall_darwin.go @@ -0,0 +1,128 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "syscall" + "unsafe" +) + +func getxattr(path string, attr string, dest []byte) (sz int, errno int) { + pathBs, err := syscall.BytePtrFromString(path) + if err != nil { + return 0, int(syscall.EINVAL) + } + + attrBs, err := syscall.BytePtrFromString(attr) + if err != nil { + return 0, int(syscall.EINVAL) + } + + size, _, errNo := syscall.Syscall6( + syscall.SYS_GETXATTR, + uintptr(unsafe.Pointer(pathBs)), + uintptr(unsafe.Pointer(attrBs)), + uintptr(unsafe.Pointer(&dest[0])), + uintptr(len(dest)), + 0, 0) + return int(size), int(errNo) +} + +func GetXAttr(path string, attr string, dest []byte) (value []byte, errno int) { + sz, errno := getxattr(path, attr, dest) + + for sz > cap(dest) && errno == 0 { + dest = make([]byte, sz) + sz, errno = getxattr(path, attr, dest) + } + + if errno != 0 { + return nil, errno + } + + return dest[:sz], errno +} + +func listxattr(path string, dest []byte) (sz int, errno int) { + pathbs, err := syscall.BytePtrFromString(path) + if err != nil { + return 0, int(syscall.EINVAL) + } + + var destPointer unsafe.Pointer + if len(dest) > 0 { + destPointer = unsafe.Pointer(&dest[0]) + } + size, _, errNo := syscall.Syscall( + syscall.SYS_LISTXATTR, + uintptr(unsafe.Pointer(pathbs)), + uintptr(destPointer), + uintptr(len(dest))) + + return int(size), int(errNo) +} + +func ListXAttr(path string) (attributes []string, errno int) { + dest := make([]byte, 0) + sz, errno := listxattr(path, dest) + if errno != 0 { + return nil, errno + } + + for sz > cap(dest) && errno == 0 { + dest = make([]byte, sz) + sz, errno = listxattr(path, dest) + } + + // -1 to drop the final empty slice. + dest = dest[:sz-1] + attributesBytes := bytes.Split(dest, []byte{0}) + attributes = make([]string, len(attributesBytes)) + for i, v := range attributesBytes { + attributes[i] = string(v) + } + return attributes, errno +} + +func Setxattr(path string, attr string, data []byte, flags int) (errno int) { + pathbs, err := syscall.BytePtrFromString(path) + if err != nil { + return int(syscall.EINVAL) + } + + attrbs, err := syscall.BytePtrFromString(attr) + if err != nil { + return int(syscall.EINVAL) + } + + _, _, errNo := syscall.Syscall6( + syscall.SYS_SETXATTR, + uintptr(unsafe.Pointer(pathbs)), + uintptr(unsafe.Pointer(attrbs)), + uintptr(unsafe.Pointer(&data[0])), + uintptr(len(data)), + uintptr(flags), 0) + + return int(errNo) +} + +func Removexattr(path string, attr string) (errno int) { + pathbs, err := syscall.BytePtrFromString(path) + if err != nil { + return int(syscall.EINVAL) + } + + attrbs, err := syscall.BytePtrFromString(attr) + if err != nil { + return int(syscall.EINVAL) + } + + _, _, errNo := syscall.Syscall( + syscall.SYS_REMOVEXATTR, + uintptr(unsafe.Pointer(pathbs)), + uintptr(unsafe.Pointer(attrbs)), 0) + return int(errNo) +} diff --git a/third_party/go-fuse/fuse/syscall_linux.go b/third_party/go-fuse/fuse/syscall_linux.go new file mode 100644 index 000000000..f28593137 --- /dev/null +++ b/third_party/go-fuse/fuse/syscall_linux.go @@ -0,0 +1,14 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "golang.org/x/sys/unix" +) + +func writev(fd int, packet [][]byte) (n int, err error) { + n, err = unix.Writev(fd, packet) + return +} diff --git a/third_party/go-fuse/fuse/syscall_unix.go b/third_party/go-fuse/fuse/syscall_unix.go new file mode 100644 index 000000000..4d09d0f38 --- /dev/null +++ b/third_party/go-fuse/fuse/syscall_unix.go @@ -0,0 +1,43 @@ +//go:build !linux + +package fuse + +import ( + "syscall" + "unsafe" +) + +func sys_writev(fd int, iovecs *syscall.Iovec, cnt int) (n int, err error) { + n1, _, e1 := syscall.Syscall( + syscall.SYS_WRITEV, + uintptr(fd), uintptr(unsafe.Pointer(iovecs)), uintptr(cnt)) + n = int(n1) + if e1 != 0 { + err = syscall.Errno(e1) + } + return n, err +} + +// Until golang.orgx/sys/unix provides Writev for Darwin and FreeBSD, +// keep the syscall wrapping funcitons. +func writev(fd int, packet [][]byte) (n int, err error) { + iovecs := make([]syscall.Iovec, 0, len(packet)) + + for _, v := range packet { + if len(v) == 0 { + continue + } + vec := syscall.Iovec{ + Base: &v[0], + } + vec.SetLen(len(v)) + iovecs = append(iovecs, vec) + } + + err = handleEINTR(func() error { + var err error + n, err = sys_writev(fd, &iovecs[0], len(iovecs)) + return err + }) + return n, err +} diff --git a/third_party/go-fuse/fuse/typeprint.go b/third_party/go-fuse/fuse/typeprint.go new file mode 100644 index 000000000..dda4a9ff2 --- /dev/null +++ b/third_party/go-fuse/fuse/typeprint.go @@ -0,0 +1,9 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +func (a *Attr) String() string { + return Print(a) +} diff --git a/third_party/go-fuse/fuse/types.go b/third_party/go-fuse/fuse/types.go new file mode 100644 index 000000000..dde862045 --- /dev/null +++ b/third_party/go-fuse/fuse/types.go @@ -0,0 +1,905 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "io" + "syscall" + "time" + "unsafe" +) + +const ( + _DEFAULT_BACKGROUND_TASKS = 12 +) + +// Status is the errno number that a FUSE call returns to the kernel. +type Status int32 + +const ( + OK = Status(0) + + // EACCESS Permission denied + EACCES = Status(syscall.EACCES) + + // EBUSY Device or resource busy + EBUSY = Status(syscall.EBUSY) + + // EAGAIN Resource temporarily unavailable + EAGAIN = Status(syscall.EAGAIN) + + // EINTR Call was interrupted + EINTR = Status(syscall.EINTR) + + // EINVAL Invalid argument + EINVAL = Status(syscall.EINVAL) + + // EIO I/O error + EIO = Status(syscall.EIO) + + // ENOENT No such file or directory + ENOENT = Status(syscall.ENOENT) + + // ENOSYS Function not implemented + ENOSYS = Status(syscall.ENOSYS) + + // ENOTDIR Not a directory + ENOTDIR = Status(syscall.ENOTDIR) + + // ENOTSUP Not supported + ENOTSUP = Status(syscall.ENOTSUP) + + // EISDIR Is a directory + EISDIR = Status(syscall.EISDIR) + + // EPERM Operation not permitted + EPERM = Status(syscall.EPERM) + + // ERANGE Math result not representable + ERANGE = Status(syscall.ERANGE) + + // EXDEV Cross-device link + EXDEV = Status(syscall.EXDEV) + + // EBADF Bad file number + EBADF = Status(syscall.EBADF) + + // ENODEV No such device + ENODEV = Status(syscall.ENODEV) + + // EROFS Read-only file system + EROFS = Status(syscall.EROFS) +) + +type ForgetIn struct { + InHeader + + Nlookup uint64 +} + +// batch forget is handled internally. +type _ForgetOne struct { + NodeId uint64 + Nlookup uint64 +} + +// batch forget is handled internally. +type _BatchForgetIn struct { + InHeader + Count uint32 + Dummy uint32 +} + +type MkdirIn struct { + InHeader + + // The mode for the new directory. The calling process' umask + // is already factored into the mode. + Mode uint32 + Umask uint32 +} + +type RenameIn struct { + InHeader + Newdir uint64 + Flags uint32 + Padding uint32 +} + +const compatRenameInSize = int(unsafe.Sizeof(InHeader{})) + 8 + +func renameInSize(kernelSettings *InitIn) int { + if !kernelSettings.supportsRenameSwap() { + return compatRenameInSize + } + return int(unsafe.Sizeof(RenameIn{})) +} + +type LinkIn struct { + InHeader + Oldnodeid uint64 +} + +type Owner struct { + Uid uint32 + Gid uint32 +} + +const ( // SetAttrIn.Valid + FATTR_MODE = (1 << 0) + FATTR_UID = (1 << 1) + FATTR_GID = (1 << 2) + FATTR_SIZE = (1 << 3) + FATTR_ATIME = (1 << 4) + FATTR_MTIME = (1 << 5) + FATTR_FH = (1 << 6) + FATTR_ATIME_NOW = (1 << 7) + FATTR_MTIME_NOW = (1 << 8) + FATTR_LOCKOWNER = (1 << 9) + FATTR_CTIME = (1 << 10) + FATTR_KILL_SUIDGID = (1 << 11) +) + +type SetAttrInCommon struct { + InHeader + + Valid uint32 + Padding uint32 + Fh uint64 + Size uint64 + LockOwner uint64 + Atime uint64 + Mtime uint64 + Ctime uint64 + Atimensec uint32 + Mtimensec uint32 + Ctimensec uint32 + Mode uint32 + Unused4 uint32 + Owner + Unused5 uint32 +} + +// GetFh returns the file handle if available, or 0 if undefined. +func (s *SetAttrInCommon) GetFh() (uint64, bool) { + if s.Valid&FATTR_FH != 0 { + return s.Fh, true + } + return 0, false +} + +func (s *SetAttrInCommon) GetMode() (uint32, bool) { + if s.Valid&FATTR_MODE != 0 { + return s.Mode & 07777, true + } + return 0, false +} + +func (s *SetAttrInCommon) GetUID() (uint32, bool) { + if s.Valid&FATTR_UID != 0 { + return s.Uid, true + } + return ^uint32(0), false +} + +func (s *SetAttrInCommon) GetGID() (uint32, bool) { + if s.Valid&FATTR_GID != 0 { + return s.Gid, true + } + return ^uint32(0), false +} + +func (s *SetAttrInCommon) GetSize() (uint64, bool) { + if s.Valid&FATTR_SIZE != 0 { + return s.Size, true + } + return 0, false +} + +func (s *SetAttrInCommon) GetMTime() (time.Time, bool) { + var t time.Time + if s.Valid&FATTR_MTIME != 0 { + if s.Valid&FATTR_MTIME_NOW != 0 { + t = time.Now() + } else { + t = time.Unix(int64(s.Mtime), int64(s.Mtimensec)) + } + return t, true + } + + return t, false +} + +func (s *SetAttrInCommon) GetATime() (time.Time, bool) { + var t time.Time + if s.Valid&FATTR_ATIME != 0 { + if s.Valid&FATTR_ATIME_NOW != 0 { + t = time.Now() + } else { + t = time.Unix(int64(s.Atime), int64(s.Atimensec)) + } + return t, true + } + + return t, false +} + +func (s *SetAttrInCommon) GetCTime() (time.Time, bool) { + var t time.Time + if s.Valid&FATTR_CTIME != 0 { + t = time.Unix(int64(s.Ctime), int64(s.Ctimensec)) + return t, true + } + + return t, false +} + +const RELEASE_FLUSH = (1 << 0) + +type ReleaseIn struct { + InHeader + Fh uint64 + Flags uint32 + ReleaseFlags uint32 + LockOwner uint64 +} + +type OpenIn struct { + InHeader + Flags uint32 + + // Mode holds open_flags (OPEN_*), not a file mode. + Mode uint32 +} + +const ( + // OpenIn.Mode, CreateIn.OpenFlags: O_TRUNC by an unprivileged caller; clear suid/sgid (CAP_HANDLE_KILLPRIV_V2). + OPEN_KILL_SUIDGID = (1 << 0) +) + +const ( + // OpenOut.Flags + FOPEN_DIRECT_IO = (1 << 0) + FOPEN_KEEP_CACHE = (1 << 1) + FOPEN_NONSEEKABLE = (1 << 2) + FOPEN_CACHE_DIR = (1 << 3) + FOPEN_STREAM = (1 << 4) + FOPEN_NOFLUSH = (1 << 5) + FOPEN_PARALLEL_DIRECT_WRITES = (1 << 6) + FOPEN_PASSTHROUGH = (1 << 7) +) + +type OpenOut struct { + Fh uint64 + OpenFlags uint32 + BackingID int32 +} + +// To be set in InitIn/InitOut.Flags. +// +// Keep in sync with either of +// * https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/fuse.h +// * https://github.com/libfuse/libfuse/blob/master/include/fuse_kernel.h +// but NOT with +// * https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h +// This file has CAP_HANDLE_KILLPRIV and CAP_POSIX_ACL reversed! +const ( + CAP_ASYNC_READ = (1 << 0) + CAP_POSIX_LOCKS = (1 << 1) + CAP_FILE_OPS = (1 << 2) + CAP_ATOMIC_O_TRUNC = (1 << 3) + CAP_EXPORT_SUPPORT = (1 << 4) + CAP_BIG_WRITES = (1 << 5) + CAP_DONT_MASK = (1 << 6) + CAP_SPLICE_WRITE = (1 << 7) + CAP_SPLICE_MOVE = (1 << 8) + CAP_SPLICE_READ = (1 << 9) + CAP_FLOCK_LOCKS = (1 << 10) + CAP_IOCTL_DIR = (1 << 11) + CAP_AUTO_INVAL_DATA = (1 << 12) // mtime changes invalidate page cache. + CAP_READDIRPLUS = (1 << 13) + CAP_READDIRPLUS_AUTO = (1 << 14) + CAP_ASYNC_DIO = (1 << 15) + CAP_WRITEBACK_CACHE = (1 << 16) + CAP_NO_OPEN_SUPPORT = (1 << 17) + CAP_PARALLEL_DIROPS = (1 << 18) + CAP_HANDLE_KILLPRIV = (1 << 19) + CAP_POSIX_ACL = (1 << 20) + CAP_ABORT_ERROR = (1 << 21) + CAP_MAX_PAGES = (1 << 22) + CAP_CACHE_SYMLINKS = (1 << 23) + + /* bits 24..31 differ across linux and mac */ + /* bits 32..63 get shifted down 32 bits into the Flags2 field */ + CAP_SECURITY_CTX = (1 << 32) + CAP_HAS_INODE_DAX = (1 << 33) + CAP_CREATE_SUPP_GROUP = (1 << 34) + CAP_HAS_EXPIRE_ONLY = (1 << 35) + CAP_DIRECT_IO_ALLOW_MMAP = (1 << 36) + CAP_PASSTHROUGH = (1 << 37) + CAP_NO_EXPORT_SUPPORT = (1 << 38) + CAP_HAS_RESEND = (1 << 39) + CAP_ALLOW_IDMAP = (1 << 40) + CAP_OVER_IO_URING = (1 << 41) + CAP_REQUEST_TIMEOUT = (1 << 42) +) + +type InitIn struct { + InHeader + + Major uint32 + Minor uint32 + MaxReadAhead uint32 + Flags uint32 + Flags2 uint32 + Unused [11]uint32 +} + +func (i *InitIn) Flags64() uint64 { + return uint64(i.Flags) | uint64(i.Flags2)<<32 +} + +type InitOut struct { + Major uint32 + Minor uint32 + MaxReadAhead uint32 + Flags uint32 + MaxBackground uint16 + CongestionThreshold uint16 + MaxWrite uint32 + TimeGran uint32 + MaxPages uint16 + MapAlignment uint16 + Flags2 uint32 + MaxStackDepth uint32 + RequestTimeout uint16 + + _Unused [11]uint16 +} + +func (o *InitOut) Flags64() uint64 { + return uint64(o.Flags) | uint64(o.Flags2)<<32 +} + +type _CuseInitIn struct { + InHeader + Major uint32 + Minor uint32 + Unused uint32 + Flags uint32 +} + +type _CuseInitOut struct { + Major uint32 + Minor uint32 + Unused uint32 + Flags uint32 + MaxRead uint32 + MaxWrite uint32 + DevMajor uint32 + DevMinor uint32 + Spare [10]uint32 +} + +type InterruptIn struct { + InHeader + Unique uint64 +} + +type _BmapIn struct { + InHeader + Block uint64 + Blocksize uint32 + Padding uint32 +} + +type _BmapOut struct { + Block uint64 +} + +const ( + IOCTL_COMPAT = (1 << 0) + IOCTL_UNRESTRICTED = (1 << 1) + IOCTL_RETRY = (1 << 2) + IOCTL_DIR = (1 << 4) +) + +type IoctlIn struct { + InHeader + Fh uint64 + Flags uint32 + + // The command, consisting of 16-bits metadata (direction + size) and 16-bits to encode the ioctl number + Cmd uint32 + + // The uint64 argument. If there is a payload, this is the + // payload address in the caller, and should not be used. + Arg uint64 + + // Size for the payload, non-zero if Cmd is WRITE or READ/WRITE. + InSize uint32 + + // Size for the payload, non-zero if Cmd is READ or READ/WRITE. + OutSize uint32 +} + +type IoctlOut struct { + Result int32 + + // The following fields are used for unrestricted ioctls, + // which are only enabled on CUSE. + Flags uint32 + InIovs uint32 + OutIovs uint32 +} + +type _PollIn struct { + InHeader + Fh uint64 + Kh uint64 + Flags uint32 + Padding uint32 +} + +type _PollOut struct { + Revents uint32 + Padding uint32 +} + +type _NotifyPollWakeupOut struct { + Kh uint64 +} + +type WriteOut struct { + Size uint32 + Padding uint32 +} + +type GetXAttrOut struct { + Size uint32 + Padding uint32 +} + +type FileLock struct { + Start uint64 + End uint64 + Typ uint32 + Pid uint32 +} + +const LK_FLOCK = (1 << 0) + +type LkIn struct { + InHeader + Fh uint64 + Owner uint64 + Lk FileLock + LkFlags uint32 + Padding uint32 +} + +type LkOut struct { + Lk FileLock +} + +// For AccessIn.Mask. +const ( + X_OK = 1 + W_OK = 2 + R_OK = 4 + F_OK = 0 +) + +type AccessIn struct { + InHeader + Mask uint32 + Padding uint32 +} + +type FsyncIn struct { + InHeader + Fh uint64 + FsyncFlags uint32 + Padding uint32 +} + +type OutHeader struct { + Length uint32 + Status int32 + Unique uint64 +} + +type NotifyInvalInodeOut struct { + Ino uint64 + Off int64 + Length int64 +} + +type NotifyInvalEntryOut struct { + Parent uint64 + NameLen uint32 + Flags uint32 +} + +const ( + // NotifyInvalEntryOut.Flags: only expire the entry's timeout, + // rather than dropping it from the dcache. Needs + // CAP_HAS_EXPIRE_ONLY. + EXPIRE_ONLY = (1 << 0) +) + +type NotifyInvalDeleteOut struct { + Parent uint64 + Child uint64 + NameLen uint32 + Padding uint32 +} + +type NotifyStoreOut struct { + Nodeid uint64 + Offset uint64 + Size uint32 + Padding uint32 +} + +type NotifyRetrieveOut struct { + NotifyUnique uint64 + Nodeid uint64 + Offset uint64 + Size uint32 + Padding uint32 +} + +type NotifyRetrieveIn struct { + InHeader + Dummy1 uint64 + Offset uint64 + Size uint32 + Dummy2 uint32 + Dummy3 uint64 + Dummy4 uint64 +} + +type NotifyPruneOut struct { + Count uint32 + _padding uint32 + _spare uint64 +} + +const ( + NOTIFY_POLL = -1 // notify kernel that a poll waiting for IO on a file handle should wake up + NOTIFY_INVAL_INODE = -2 // notify kernel that an inode should be invalidated + NOTIFY_INVAL_ENTRY = -3 // notify kernel that a directory entry should be invalidated + NOTIFY_STORE_CACHE = -4 // store data into kernel cache of an inode + NOTIFY_RETRIEVE_CACHE = -5 // retrieve data from kernel cache of an inode + NOTIFY_DELETE = -6 // notify kernel that a directory entry has been deleted + NOTIFY_RESEND = -7 + NOTIFY_INC_EPOCH = -8 + NOTIFY_PRUNE = -9 +) + +type FlushIn struct { + InHeader + Fh uint64 + Unused uint32 + Padding uint32 + LockOwner uint64 +} + +type LseekIn struct { + InHeader + Fh uint64 + Offset uint64 + Whence uint32 + Padding uint32 +} + +type LseekOut struct { + Offset uint64 +} + +type CopyFileRangeIn struct { + InHeader + FhIn uint64 + OffIn uint64 + NodeIdOut uint64 + FhOut uint64 + OffOut uint64 + Len uint64 + Flags uint64 +} + +// EntryOut holds the result of a (directory,name) lookup. It has two +// TTLs, one for the (directory, name) lookup itself, and one for the +// attributes (eg. size, mode). The entry TTL also applies if the +// lookup result is ENOENT ("negative entry lookup") +type EntryOut struct { + NodeId uint64 + Generation uint64 + EntryValid uint64 + AttrValid uint64 + EntryValidNsec uint32 + AttrValidNsec uint32 + Attr +} + +// EntryTimeout returns the timeout in nanoseconds for a directory +// entry (existence or non-existence of a file within a directory). +func (o *EntryOut) EntryTimeout() time.Duration { + return time.Duration(uint64(o.EntryValidNsec) + o.EntryValid*1e9) +} + +// AttrTimeout returns the TTL in nanoseconds of the attribute data. +func (o *EntryOut) AttrTimeout() time.Duration { + return time.Duration(uint64(o.AttrValidNsec) + o.AttrValid*1e9) +} + +// SetEntryTimeout sets the entry TTL. +func (o *EntryOut) SetEntryTimeout(dt time.Duration) { + ns := int64(dt) + o.EntryValidNsec = uint32(ns % 1e9) + o.EntryValid = uint64(ns / 1e9) +} + +// SetAttrTimeout sets the attribute TTL. +func (o *EntryOut) SetAttrTimeout(dt time.Duration) { + ns := int64(dt) + o.AttrValidNsec = uint32(ns % 1e9) + o.AttrValid = uint64(ns / 1e9) +} + +// AttrOut is the type returned by the Getattr call. +type AttrOut struct { + AttrValid uint64 + AttrValidNsec uint32 + Dummy uint32 + Attr +} + +func (o *AttrOut) Timeout() time.Duration { + return time.Duration(uint64(o.AttrValidNsec) + o.AttrValid*1e9) +} + +func (o *AttrOut) SetTimeout(dt time.Duration) { + ns := int64(dt) + o.AttrValidNsec = uint32(ns % 1e9) + o.AttrValid = uint64(ns / 1e9) +} + +type CreateOut struct { + EntryOut + OpenOut +} + +// Caller has data on the process making the FS call. +// +// The UID and GID are effective UID/GID, except for the ACCESS +// opcode, where UID and GID are the real UIDs +type Caller struct { + Owner + Pid uint32 +} + +type InHeader struct { + Length uint32 + Opcode uint32 + Unique uint64 + NodeId uint64 + Caller + TotalExtlen uint16 // in 8-byte units + Padding uint16 +} + +type StatfsOut struct { + Blocks uint64 + Bfree uint64 + Bavail uint64 + Files uint64 + Ffree uint64 + Bsize uint32 + NameLen uint32 + Frsize uint32 + Padding uint32 + Spare [6]uint32 +} + +// _Dirent is what we send to the kernel, but we offer DirEntry and +// DirEntryList to the user. +type _Dirent struct { + Ino uint64 + Off uint64 + NameLen uint32 + Typ uint32 +} + +const ( + READ_LOCKOWNER = (1 << 1) +) + +const ( + WRITE_CACHE = (1 << 0) + WRITE_LOCKOWNER = (1 << 1) + WRITE_KILL_SUIDGID = (1 << 2) +) + +type FallocateIn struct { + InHeader + Fh uint64 + Offset uint64 + Length uint64 + Mode uint32 + Padding uint32 +} + +func (lk *FileLock) ToFlockT(flockT *syscall.Flock_t) { + flockT.Start = int64(lk.Start) + if lk.End == (1<<63)-1 { + flockT.Len = 0 + } else { + flockT.Len = int64(lk.End - lk.Start + 1) + } + flockT.Whence = int16(io.SeekStart) + flockT.Type = int16(lk.Typ) +} + +func (lk *FileLock) FromFlockT(flockT *syscall.Flock_t) { + lk.Typ = uint32(flockT.Type) + if flockT.Type != syscall.F_UNLCK { + lk.Start = uint64(flockT.Start) + if flockT.Len == 0 { + lk.End = (1 << 63) - 1 + } else { + lk.End = uint64(flockT.Start + flockT.Len - 1) + } + } + lk.Pid = uint32(flockT.Pid) +} + +const ( + // Mask for GetAttrIn.Flags. If set, GetAttrIn has a file handle set. + FUSE_GETATTR_FH = (1 << 0) +) + +type GetAttrIn struct { + InHeader + + Flags_ uint32 + Dummy uint32 + Fh_ uint64 +} + +// Flags accesses the flags. This is a method, because OSXFuse does not +// have GetAttrIn flags. +func (g *GetAttrIn) Flags() uint32 { + return g.Flags_ +} + +// Fh accesses the file handle. This is a method, because OSXFuse does not +// have GetAttrIn flags. +func (g *GetAttrIn) Fh() uint64 { + return g.Fh_ +} + +type MknodIn struct { + InHeader + + // Mode to use, including the Umask value + Mode uint32 + Rdev uint32 + Umask uint32 + Padding uint32 +} + +type CreateIn struct { + InHeader + Flags uint32 + + // Mode for the new file; already takes Umask into account. + Mode uint32 + + // Umask used for this create call. + Umask uint32 + + OpenFlags uint32 +} + +type ReadIn struct { + InHeader + Fh uint64 + Offset uint64 + Size uint32 + ReadFlags uint32 + LockOwner uint64 + Flags uint32 + Padding uint32 +} + +type WriteIn struct { + InHeader + Fh uint64 + Offset uint64 + Size uint32 + WriteFlags uint32 + LockOwner uint64 + Flags uint32 + Padding uint32 +} + +// Data for registering a file as backing an inode. +type BackingMap struct { + Fd int32 + Flags uint32 + padding uint64 +} + +type SxTime struct { + Sec uint64 + Nsec uint32 + _reserved uint32 +} + +func (t *SxTime) Seconds() float64 { + return ft(t.Sec, t.Nsec) +} + +type Statx struct { + Mask uint32 + Blksize uint32 + Attributes uint64 + Nlink uint32 + + Uid uint32 + Gid uint32 + Mode uint16 + _spare0 uint16 + Ino uint64 + Size uint64 + Blocks uint64 + AttributesMask uint64 + + Atime SxTime + Btime SxTime + Ctime SxTime + Mtime SxTime + + RdevMajor uint32 + RdevMinor uint32 + DevMajor uint32 + DevMinor uint32 + _spare2 [14]uint64 +} + +type StatxIn struct { + InHeader + + GetattrFlags uint32 + _reserved uint32 + Fh uint64 + SxFlags uint32 + SxMask uint32 +} + +type StatxOut struct { + AttrValid uint64 + AttrValidNsec uint32 + Flags uint32 + _spare [2]uint64 + + Statx +} + +func (o *StatxOut) Timeout() time.Duration { + return time.Duration(uint64(o.AttrValidNsec) + o.AttrValid*1e9) +} + +func (o *StatxOut) SetTimeout(dt time.Duration) { + ns := int64(dt) + o.AttrValidNsec = uint32(ns % 1e9) + o.AttrValid = uint64(ns / 1e9) +} + +type CopyFileRangeOut struct { + BytesCopied uint64 +} diff --git a/third_party/go-fuse/fuse/types_darwin.go b/third_party/go-fuse/fuse/types_darwin.go new file mode 100644 index 000000000..91c269638 --- /dev/null +++ b/third_party/go-fuse/fuse/types_darwin.go @@ -0,0 +1,142 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + "unsafe" +) + +const ( + ENODATA = Status(syscall.ENODATA) + ENOATTR = Status(syscall.ENOATTR) // ENOATTR is not defined for all GOOS. + + // EREMOTEIO is not supported on Darwin. + EREMOTEIO = Status(syscall.EIO) +) + +type Attr struct { + Ino uint64 + Size uint64 + Blocks uint64 + Atime uint64 + Mtime uint64 + Ctime uint64 + Crtime_ uint64 // OS X + Atimensec uint32 + Mtimensec uint32 + Ctimensec uint32 + Crtimensec_ uint32 // OS X + Mode uint32 + Nlink uint32 + Owner + Rdev uint32 + Flags_ uint32 // OS X + Blksize uint32 + Padding uint32 +} + +const ( + FATTR_CRTIME = (1 << 28) + FATTR_CHGTIME = (1 << 29) + FATTR_BKUPTIME = (1 << 30) + FATTR_FLAGS = (1 << 31) +) + +type SetAttrIn struct { + SetAttrInCommon + + // OS X only + Bkuptime_ uint64 + Chgtime_ uint64 + Crtime uint64 + BkuptimeNsec uint32 + ChgtimeNsec uint32 + CrtimeNsec uint32 + Flags_ uint32 // see chflags(2) +} + +const ( + FOPEN_PURGE_ATTR = (1 << 30) + FOPEN_PURGE_UBC = (1 << 31) +) + +type SetXAttrIn struct { + InHeader + Size uint32 + Flags uint32 + Position uint32 + Padding uint32 +} + +func setXAttrInSize(negotiated uint64) int { + return int(unsafe.Sizeof(SetXAttrIn{})) +} + +type GetXAttrIn struct { + InHeader + Size uint32 + Padding uint32 + Position uint32 + Padding2 uint32 +} + +const ( + CAP_NODE_RWLOCK = (1 << 24) + CAP_RENAME_SWAP = (1 << 25) + CAP_RENAME_EXCL = (1 << 26) + CAP_ALLOCATE = (1 << 27) + CAP_EXCHANGE_DATA = (1 << 28) + CAP_CASE_INSENSITIVE = (1 << 29) + CAP_VOL_RENAME = (1 << 30) + CAP_XTIMES = (1 << 31) + + // CAP_EXPLICIT_INVAL_DATA is not supported on Darwin. + CAP_EXPLICIT_INVAL_DATA = 0x0 +) + +type GetxtimesOut struct { + Bkuptime uint64 + Crtime uint64 + Bkuptimensec uint32 + Crtimensec uint32 +} + +type ExchangeIn struct { + InHeader + Olddir uint64 + Newdir uint64 + Options uint64 +} + +type MonitorIn struct { + InHeader + Flags uint32 + Padding uint32 +} + +func (s *StatfsOut) FromStatfsT(statfs *syscall.Statfs_t) { + s.Blocks = statfs.Blocks + s.Bfree = statfs.Bfree + s.Bavail = statfs.Bavail + s.Files = statfs.Files + s.Ffree = statfs.Ffree + s.Bsize = uint32(statfs.Iosize) // Iosize translates to Bsize: the optimal transfer size. + s.Frsize = s.Bsize // Bsize translates to Frsize: the minimum transfer size. + + // The block counts are in units of statfs.Bsize. + // If s.Bsize != statfs.Bsize, we have to recalculate the block counts + // accordingly (s.Bsize is usually 256*statfs.Bsize). + if s.Bsize > statfs.Bsize { + adj := uint64(s.Bsize / statfs.Bsize) + s.Blocks /= adj + s.Bfree /= adj + s.Bavail /= adj + } +} + +func (o *InitOut) setFlags(flags uint64) { + o.Flags = uint32(flags) +} diff --git a/third_party/go-fuse/fuse/types_freebsd.go b/third_party/go-fuse/fuse/types_freebsd.go new file mode 100644 index 000000000..a7a551ea3 --- /dev/null +++ b/third_party/go-fuse/fuse/types_freebsd.go @@ -0,0 +1,38 @@ +package fuse + +import "syscall" + +const ( + ENOATTR = Status(syscall.ENOATTR) + ENODATA = Status(syscall.EIO) +) + +const ( + CAP_NO_OPENDIR_SUPPORT = (1 << 24) + + // The higher capabilities are not currently defined by FreeBSD. + // Ref: https://cgit.freebsd.org/src/tree/sys/fs/fuse/fuse_kernel.h + + // CAP_RENAME_SWAP only exists on OSX. + CAP_RENAME_SWAP = 0x0 + + // CAP_EXPLICIT_INVAL_DATA is not supported on FreeBSD. + CAP_EXPLICIT_INVAL_DATA = 0x0 + + CAP_SETXATTR_EXT = 0x0 +) + +func (s *StatfsOut) FromStatfsT(statfs *syscall.Statfs_t) { + s.Blocks = statfs.Blocks + s.Bsize = uint32(statfs.Bsize) + s.Bfree = statfs.Bfree + s.Bavail = uint64(statfs.Bavail) + s.Files = statfs.Files + s.Ffree = uint64(statfs.Ffree) + s.Frsize = uint32(statfs.Bsize) + s.NameLen = uint32(statfs.Namemax) +} + +func (o *InitOut) setFlags(flags uint64) { + o.Flags = uint32(flags) +} diff --git a/third_party/go-fuse/fuse/types_linux.go b/third_party/go-fuse/fuse/types_linux.go new file mode 100644 index 000000000..40593c5d7 --- /dev/null +++ b/third_party/go-fuse/fuse/types_linux.go @@ -0,0 +1,59 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +const ( + ENODATA = Status(syscall.ENODATA) + ENOATTR = Status(syscall.ENODATA) // On Linux, ENOATTR is an alias for ENODATA. + + // EREMOTEIO Remote I/O error + EREMOTEIO = Status(syscall.EREMOTEIO) +) + +// To be set in InitIn/InitOut.Flags. +// +// This flags conflict with https://github.com/macfuse/library/blob/master/include/fuse_common.h +// and should be used only on Linux. +const ( + CAP_NO_OPENDIR_SUPPORT = (1 << 24) + CAP_EXPLICIT_INVAL_DATA = (1 << 25) + + CAP_MAP_ALIGNMENT = (1 << 26) + CAP_SUBMOUNTS = (1 << 27) + CAP_HANDLE_KILLPRIV_V2 = (1 << 28) + CAP_SETXATTR_EXT = (1 << 29) + CAP_INIT_EXT = (1 << 30) + CAP_INIT_RESERVED = (1 << 31) + + // CAP_RENAME_SWAP only exists on OSX. + CAP_RENAME_SWAP = 0x0 +) + +func (s *StatfsOut) FromStatfsT(statfs *syscall.Statfs_t) { + s.Blocks = statfs.Blocks + s.Bsize = uint32(statfs.Bsize) + s.Bfree = statfs.Bfree + s.Bavail = statfs.Bavail + s.Files = statfs.Files + s.Ffree = statfs.Ffree + s.Frsize = uint32(statfs.Frsize) + s.NameLen = uint32(statfs.Namelen) +} + +func (o *InitOut) setFlags(flags uint64) { + o.Flags = uint32(flags) | CAP_INIT_EXT + o.Flags2 = uint32(flags >> 32) +} + +func (t *SxTime) FromStatxTimestamp(ts *unix.StatxTimestamp) { + t.Sec = uint64(ts.Sec) + t.Nsec = ts.Nsec +} diff --git a/third_party/go-fuse/fuse/types_unix.go b/third_party/go-fuse/fuse/types_unix.go new file mode 100644 index 000000000..d8114b232 --- /dev/null +++ b/third_party/go-fuse/fuse/types_unix.go @@ -0,0 +1,68 @@ +//go:build !darwin + +package fuse + +import "unsafe" + +type Attr struct { + Ino uint64 + Size uint64 + + // Blocks is the number of 512-byte blocks that the file occupies on disk. + Blocks uint64 + Atime uint64 + Mtime uint64 + Ctime uint64 + Atimensec uint32 + Mtimensec uint32 + Ctimensec uint32 + Mode uint32 + Nlink uint32 + Owner + Rdev uint32 + + // Blksize is the preferred size for file system operations. + Blksize uint32 + + Flags uint32 +} + +const ( + // Attr.Flags: directory is the root of a server-side submount; virtio-fs automounts it (CAP_SUBMOUNTS). + ATTR_SUBMOUNT = (1 << 0) + // Attr.Flags: map the file through the virtio-fs DAX window (CAP_HAS_INODE_DAX, dax=inode). + ATTR_DAX = (1 << 1) +) + +type SetAttrIn struct { + SetAttrInCommon +} + +type SetXAttrIn struct { + InHeader + Size uint32 + Flags uint32 + + SetXAttrFlags uint32 + Padding uint32 +} + +const ( + // SetXAttrIn.SetXAttrFlags: setting system.posix_acl_access must clear sgid. + SETXATTR_ACL_KILL_SGID = (1 << 0) +) + +const compatSetXAttrInSize = int(unsafe.Sizeof(InHeader{})) + 8 + +func setXAttrInSize(negotiated uint64) int { + if negotiated&CAP_SETXATTR_EXT == 0 { + return compatSetXAttrInSize + } + return int(unsafe.Sizeof(SetXAttrIn{})) +} + +type GetXAttrIn struct { + InHeader + Size uint32 + Padding uint32 +} diff --git a/third_party/go-fuse/go.mod b/third_party/go-fuse/go.mod new file mode 100644 index 000000000..65e3055de --- /dev/null +++ b/third_party/go-fuse/go.mod @@ -0,0 +1,15 @@ +module github.com/hanwen/go-fuse/v2 + +require ( + github.com/kylelemons/godebug v1.1.0 + github.com/moby/sys/mountinfo v0.7.2 + golang.org/x/sync v0.10.0 + golang.org/x/sys v0.28.0 +) + +require ( + github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect + github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 +) + +go 1.21 diff --git a/third_party/go-fuse/splice/copy.go b/third_party/go-fuse/splice/copy.go new file mode 100644 index 000000000..0c36195f2 --- /dev/null +++ b/third_party/go-fuse/splice/copy.go @@ -0,0 +1,72 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "io" + "os" +) + +func SpliceCopy(dst *os.File, src *os.File, p *Pair) (int64, error) { + total := int64(0) + + for { + n, err := p.LoadFrom(src.Fd(), p.size) + if err != nil { + return total, err + } + if n == 0 { + break + } + m, err := p.WriteTo(dst.Fd(), n) + total += int64(m) + if err != nil { + return total, err + } + if m < n { + return total, err + } + if int(n) < p.size { + break + } + } + + return total, nil +} + +// Argument ordering follows io.Copy. +func CopyFile(dstName string, srcName string, mode int) error { + src, err := os.Open(srcName) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(mode)) + if err != nil { + return err + } + defer dst.Close() + + return CopyFds(dst, src) +} + +func CopyFds(dst *os.File, src *os.File) (err error) { + p, err := splicePool.get() + if p != nil { + p.Grow(256 * 1024) + _, err := SpliceCopy(dst, src, p) + splicePool.done(p) + return err + } else { + _, err = io.Copy(dst, src) + } + if err == io.EOF { + err = nil + } + return err +} diff --git a/third_party/go-fuse/splice/pair.go b/third_party/go-fuse/splice/pair.go new file mode 100644 index 000000000..47601c0d5 --- /dev/null +++ b/third_party/go-fuse/splice/pair.go @@ -0,0 +1,95 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "fmt" + "log" + "os" + "syscall" +) + +type Pair struct { + r, w *os.File + rConn, wConn syscall.RawConn + size int +} + +func (p *Pair) MaxGrow() { + for p.Grow(2*p.size) == nil { + } +} + +func (p *Pair) Grow(n int) error { + if n <= p.size { + return nil + } + if !resizable { + return fmt.Errorf("splice: want %d bytes, but not resizable", n) + } + if n > maxPipeSize { + return fmt.Errorf("splice: want %d bytes, max pipe size %d", n, maxPipeSize) + } + + var newsize int + var errNo syscall.Errno + p.rConn.Control(func(fd uintptr) { + newsize, errNo = fcntl(fd, F_SETPIPE_SZ, n) + }) + if errNo != 0 { + return fmt.Errorf("splice: fcntl returned %v", errNo) + } + p.size = newsize + return nil +} + +func (p *Pair) Cap() int { + return p.size +} + +func (p *Pair) Close() error { + if p.r == nil { + log.Panicf("2nd close r") + } + err1 := p.r.Close() + p.r = nil + if p.w == nil { + log.Panicf("2nd close w") + } + err2 := p.w.Close() + p.w = nil + if err1 != nil { + return err1 + } + return err2 +} + +func (p *Pair) Read(d []byte) (n int, err error) { + p.rConn.Control(func(fd uintptr) { + n, err = syscall.Read(int(fd), d) + }) + return +} + +func (p *Pair) Write(d []byte) (n int, err error) { + p.wConn.Control(func(fd uintptr) { + n, err = syscall.Write(int(fd), d) + }) + return +} + +func (p *Pair) ReadFd() uintptr { + var fd uintptr + p.rConn.Control(func(f uintptr) { fd = f }) + return fd +} + +func (p *Pair) WriteFd() uintptr { + var fd uintptr + p.wConn.Control(func(f uintptr) { fd = f }) + return fd +} diff --git a/third_party/go-fuse/splice/pair_linux.go b/third_party/go-fuse/splice/pair_linux.go new file mode 100644 index 000000000..2d44fae4b --- /dev/null +++ b/third_party/go-fuse/splice/pair_linux.go @@ -0,0 +1,118 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "fmt" + "log" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func (p *Pair) Writev(bufs [][]byte) (int, error) { + var n int + var err error + p.wConn.Control(func(wfd uintptr) { + n, err = unix.Writev(int(wfd), bufs) + }) + return n, err +} + +func (p *Pair) SpliceFrom(src *Pair, sz int) (int, error) { + var n int + var err error + src.rConn.Control(func(rfd uintptr) { + p.wConn.Control(func(wfd uintptr) { + var sn int64 + sn, err = syscall.Splice(int(rfd), nil, int(wfd), nil, sz, 0) + n = int(sn) + }) + }) + if err != nil { + err = os.NewSyscallError("Splice", err) + } + return n, err +} + +func (p *Pair) LoadFromAt(fd uintptr, sz int, off int64) (int, error) { + var n int + var err error + p.wConn.Control(func(wfd uintptr) { + var sn int64 + sn, err = syscall.Splice(int(fd), &off, int(wfd), nil, sz, 0) + n = int(sn) + }) + return n, err +} + +func (p *Pair) LoadFrom(fd uintptr, sz int) (int, error) { + if sz > p.size { + return 0, fmt.Errorf("LoadFrom: not enough space %d, %d", + sz, p.size) + } + + var n int + var err error + p.wConn.Control(func(wfd uintptr) { + var sn int64 + sn, err = syscall.Splice(int(fd), nil, int(wfd), nil, sz, 0) + n = int(sn) + }) + if err != nil { + err = os.NewSyscallError("Splice load from", err) + } + return n, err +} + +func (p *Pair) WriteTo(fd uintptr, n int) (int, error) { + return p.WriteToFlags(fd, n, 0) +} + +// WriteToFlags is WriteTo with splice(2) flags. /dev/fuse acts on SPLICE_F_MOVE +// even though the generic pipe-to-file path ignores it. +func (p *Pair) WriteToFlags(fd uintptr, n int, flags int) (int, error) { + var m int + var err error + p.rConn.Control(func(rfd uintptr) { + var sm int64 + sm, err = syscall.Splice(int(rfd), nil, int(fd), nil, n, flags) + m = int(sm) + }) + if err != nil { + err = os.NewSyscallError("Splice write", err) + } + return m, err +} + +const ( + _SPLICE_F_NONBLOCK = 0x2 + _FIONREAD = 0x541b +) + +func (p *Pair) discard() { + for { + var err error + p.rConn.Control(func(rfd uintptr) { + _, err = syscall.Splice(int(rfd), nil, devNullFD(), nil, int(p.size), _SPLICE_F_NONBLOCK) + }) + if err != nil && err != syscall.EAGAIN { + // This can happen if something closed our fd inadvertently (eg. double close) + log.Panicf("splicing into /dev/null: %v", err) + } + + var n int + p.rConn.Control(func(rfd uintptr) { + n, err = unix.IoctlGetInt(int(rfd), _FIONREAD) + }) + if err != nil { + log.Panicf("FIONREAD on pipe: %v", err) + } + if n == 0 { + return + } + } +} diff --git a/third_party/go-fuse/splice/pool.go b/third_party/go-fuse/splice/pool.go new file mode 100644 index 000000000..5576df5ab --- /dev/null +++ b/third_party/go-fuse/splice/pool.go @@ -0,0 +1,107 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "sync" +) + +var splicePool *pairPool + +type pairPool struct { + sync.Mutex + unused []*Pair + usedCount int +} + +func ClearSplicePool() { + splicePool.clear() +} + +func Get() (*Pair, error) { + return splicePool.get() +} + +func Total() int { + return splicePool.total() +} + +func Used() int { + return splicePool.used() +} + +// Done returns the pipe pair to pool. +func Done(p *Pair) { + splicePool.done(p) +} + +// Closes and discards pipe pair. +func Drop(p *Pair) { + splicePool.drop(p) +} + +func newSplicePairPool() *pairPool { + return &pairPool{} +} + +func (pp *pairPool) clear() { + pp.Lock() + for _, p := range pp.unused { + p.Close() + } + pp.unused = pp.unused[:0] + pp.Unlock() +} + +func (pp *pairPool) used() (n int) { + pp.Lock() + n = pp.usedCount + pp.Unlock() + + return n +} + +func (pp *pairPool) total() int { + pp.Lock() + n := pp.usedCount + len(pp.unused) + pp.Unlock() + return n +} + +func (pp *pairPool) drop(p *Pair) { + p.Close() + pp.Lock() + pp.usedCount-- + pp.Unlock() +} + +func (pp *pairPool) get() (p *Pair, err error) { + pp.Lock() + defer pp.Unlock() + + pp.usedCount++ + l := len(pp.unused) + if l > 0 { + p := pp.unused[l-1] + pp.unused = pp.unused[:l-1] + return p, nil + } + + return newSplicePair() +} + +func (pp *pairPool) done(p *Pair) { + p.discard() + pp.Lock() + pp.usedCount-- + pp.unused = append(pp.unused, p) + pp.Unlock() +} + +func init() { + splicePool = newSplicePairPool() +} diff --git a/third_party/go-fuse/splice/splice.go b/third_party/go-fuse/splice/splice.go new file mode 100644 index 000000000..9b851d190 --- /dev/null +++ b/third_party/go-fuse/splice/splice.go @@ -0,0 +1,114 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +// Routines for efficient file to file copying. + +import ( + "fmt" + "log" + "os" + "sync" + "syscall" +) + +var maxPipeSize int +var resizable bool + +func Resizable() bool { + return resizable +} + +func MaxPipeSize() int { + return maxPipeSize +} + +// From manpage on ubuntu Lucid: +// +// Since Linux 2.6.11, the pipe capacity is 65536 bytes. +const DefaultPipeSize = 16 * 4096 + +var ( + devNullFDOnce sync.Once + devNullFDValue int +) + +func init() { + content, err := os.ReadFile("/proc/sys/fs/pipe-max-size") + if err != nil { + maxPipeSize = DefaultPipeSize + } else { + fmt.Sscan(string(content), &maxPipeSize) + } + + r, w, err := os.Pipe() + if err != nil { + log.Panicf("cannot create pipe: %v", err) + } + sz, errNo := fcntl(r.Fd(), F_GETPIPE_SZ, 0) + resizable = (errNo == 0) + _, errNo = fcntl(r.Fd(), F_SETPIPE_SZ, 2*sz) + resizable = resizable && (errNo == 0) + r.Close() + w.Close() +} + +// We empty pipes by splicing to /dev/null. +func devNullFD() int { + devNullFDOnce.Do(func() { + fd, err := syscall.Open("/dev/null", os.O_WRONLY, 0) + if err != nil { + panic(fmt.Sprintf("failed to open /dev/null: %s", err)) + } + devNullFDValue = fd + }) + return devNullFDValue +} + +// copy & paste from syscall. +func fcntl(fd uintptr, cmd int, arg int) (val int, errno syscall.Errno) { + r0, _, e1 := syscall.Syscall(syscall.SYS_FCNTL, fd, uintptr(cmd), uintptr(arg)) + val = int(r0) + errno = syscall.Errno(e1) + return +} + +const F_SETPIPE_SZ = 1031 +const F_GETPIPE_SZ = 1032 + +func newSplicePair() (p *Pair, err error) { + p = &Pair{} + p.r, p.w, err = os.Pipe() + if err != nil { + return nil, err + } + p.rConn, err = p.r.SyscallConn() + if err != nil { + p.r.Close() + p.w.Close() + return nil, err + } + p.wConn, err = p.w.SyscallConn() + if err != nil { + p.r.Close() + p.w.Close() + return nil, err + } + var errNo syscall.Errno + p.rConn.Control(func(fd uintptr) { + p.size, errNo = fcntl(fd, F_GETPIPE_SZ, 0) + }) + if errNo == syscall.EINVAL { + p.size = DefaultPipeSize + return p, nil + } + if errNo != 0 { + p.Close() + return nil, fmt.Errorf("fcntl getsize: %v", errNo) + } + return p, nil +}