Files
seaweedfs/weed/server/filer_server_handlers_write.go
T
Chris LuandJavier de la Torre 82f0a82564 filer+volume: make acknowledged writes durable under the fsync rule (#11943)
* volume: never compact a volume tiered to remote storage

A tiered volume's data backend is the remote object, but compaction reads
the local .dat path. When tiering removed the local .dat, every vacuum of a
tiered volume with garbage fails with "no such file or directory", and the
master's sweep and the worker's vacuum task keep selecting it. When the
local .dat was kept, the compaction succeeds against the stale copy and the
commit reloads the volume to read the remote object (old layout) through the
compacted index, so live needles stop reading.

Mirror the Rust volume server (#11545):
- CompactByIndex and CompactByVolumeData refuse a volume with a remote file
  before creating .cpd/.cpx ("volume N is tiered to remote storage, cannot
  compact").
- CommitCompact refuses under the data-file locks when the volume became
  tiered during the copy, and removes .cpd/.cpx.
- Store.CheckCompactVolume reports no garbage for a tiered volume, so the
  master's vacuum and volume.vacuum do not select it.
- The worker's vacuum detection skips a volume with a remote copy, as balance
  and EC detection already do.

Tests (each fails on master): TestCompactRefusesTieredVolume,
TestCommitCompactRefusesVolumeTieredDuringCopy (on master the commit
succeeds and a surviving needle no longer reads),
TestPickPrimaryReplicaSkipsTieredVolumes.

* volume: skip tiered replicas in vacuum selection, keep their real ratio

Reporting a zero garbage ratio for a tiered volume hid it from
volume.tier.compact, whose preflight reads that ratio while the volume is
still remote, and did not exclude it from a vacuum with a zero threshold.
Report the real ratio again, as the Rust volume server does, and have the
master's vacuum check skip a replica whose volume info names remote
storage, whatever the threshold or request.

When CommitCompact refuses a volume tiered during the copy, also close the
temporary needle map and remove .cpldb: nothing else would, since no later
vacuum selects the volume.

The tiered-compaction test no longer depends on random needle sizes
reaching a 0.3 garbage ratio.

* volume tier: do not attach a copy of a .dat a compaction replaced

The upload copies the .dat, then records the remote file in the .vif and
switches the backend. A compaction commit in between swaps in a compacted
.dat and index; the upload then served the old-layout remote object through
the new index, so live needles stopped reading. CommitCompact's tiered
check cannot see this: the volume is not tiered until the switch.

AttachRemoteDatFile records the remote file and switches the backend under
the data-file locks, and refuses when the backend is no longer the file
that was copied. The upload then removes the unused remote copy.

* volume tier: keep a committed .vif entry when only the dir fsync fails

AttachRemoteDatFile rolled back the in-memory remote entry on any
SaveVolumeInfo error. A NotCrashDurableError comes after the rename, so
the .vif on disk already names the copy; with the entry gone, the upload
handler deleted that object, and a reload would serve from a missing
remote file. Keep the entry and switch to the copy as the .vif says,
and still return the error so the caller keeps the local .dat.

* vacuum worker: filter tiered replicas instead of skipping the volume

A partially tiered volume is the leftover of an interrupted
volume.tier.upload; skipping the whole volume left garbage on its local
replicas unreclaimed. Per-replica filtering matches the master's
batchVacuumVolumeCheck and is safe: a tiered replica refuses both
compaction and commit.

* filer: sync leveldb entry writes when the path rule asks for fsync

The fsync storage rule only reached the volume upload; the metadata
writes behind an acknowledged create, update or delete still went to
the leveldb journal with Sync:false, so a power loss could roll back
an acknowledged bucket or object entry.

A write-side flag on the entry context carries the request: the filer
sets it from the path's storage rule in CreateEntry, UpdateEntry,
EnsureDirectoryEntry and DeleteEntryMetaAndData, and the HTTP write
path sets it for an explicit ?fsync=true. The leveldb stores write
with Sync:true when the flag is present.

* volume: flush the .idx before acknowledging a durable write

A write with fsync synced only the .dat: the index row written on
publish stayed in the page cache, so a power loss could leave the
needle's bytes durable with nothing indexing them.

The durable paths now run one .idx sync after publish, before the
requests complete — one per syncWrite and one per batch run, paired
with the .dat sync, the same split the Rust volume server makes
between flush_dat and flush_idx. A failed .idx sync cannot be rolled
back, so it stops the volume taking writes and fails the requests
instead of acknowledging an index entry that may not survive.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 23:57:09 -07:00

374 lines
12 KiB
Go

package weed_server
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/constants"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
var (
OS_UID = uint32(os.Getuid())
OS_GID = uint32(os.Getgid())
// ErrReadOnly is filer_pb.ErrReadOnly so errors.Is matches on either side
// of the gRPC boundary.
ErrReadOnly = filer_pb.ErrReadOnly
)
type FilerPostResult struct {
Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"`
Error string `json:"error,omitempty"`
}
func (fs *FilerServer) assignNewFileInfo(ctx context.Context, so *operation.StorageOption, expectedDataSize uint64) (fileId, urlLocation string, auth security.EncodedJwt, err error) {
stats.FilerHandlerCounter.WithLabelValues(stats.ChunkAssign).Inc()
start := time.Now()
defer func() {
stats.FilerRequestHistogram.WithLabelValues(stats.ChunkAssign).Observe(time.Since(start).Seconds())
}()
ar, altRequest := so.ToAssignRequests(1)
ar.ExpectedDataSize = expectedDataSize
if altRequest != nil {
altRequest.ExpectedDataSize = expectedDataSize
}
// Use a context that ignores cancellation from the request context
assignCtx := context.WithoutCancel(ctx)
assignResult, ae := operation.Assign(assignCtx, fs.filer.GetMaster, fs.grpcDialOption, ar, altRequest)
if ae != nil {
glog.ErrorfCtx(ctx, "failing to assign a file id: %v", ae)
err = ae
return
}
fileId = assignResult.Fid
assignUrl := assignResult.Url
// Prefer same data center
if fs.option.DataCenter != "" {
for _, repl := range assignResult.Replicas {
if repl.DataCenter == fs.option.DataCenter {
assignUrl = repl.Url
break
}
}
}
urlLocation = "http://" + assignUrl + "/" + assignResult.Fid
if so.Fsync {
urlLocation += "?fsync=true"
}
auth = assignResult.Auth
return
}
func (fs *FilerServer) PostHandler(w http.ResponseWriter, r *http.Request, contentLength int64) {
ctx := r.Context()
// Match storage rules on the decoded path the entry is written to. The raw
// request-target is percent-encoded on the wire, so a rule on "/只读/" would
// never see "/%E5%8F%AA%E8%AF%BB/".
destination := r.URL.Path
headerDestination := r.Header.Get(s3_constants.SeaweedStorageDestinationHeader)
if headerDestination != "" {
destination = headerDestination
}
// The destination header picks storage rules for a logical destination, but
// the entry is written at r.URL.Path. Enforce the read-only/quota rule on the
// actual write path too, so the header cannot route a write into a read-only
// location.
if headerDestination != "" && fs.filer.FilerConf.MatchStorageRule(r.URL.Path).ReadOnly {
writeJsonError(w, r, http.StatusInsufficientStorage, ErrReadOnly)
return
}
query := r.URL.Query()
so, err := fs.detectStorageOption0(ctx, destination,
query.Get("collection"),
query.Get("replication"),
query.Get("ttl"),
query.Get("disk"),
query.Get("fsync"),
query.Get("dataCenter"),
query.Get("rack"),
query.Get("dataNode"),
query.Get("saveInside"),
)
if err != nil {
if errors.Is(err, ErrReadOnly) {
writeJsonError(w, r, http.StatusInsufficientStorage, err)
} else {
glog.V(1).InfolnCtx(ctx, "post", r.RequestURI, ":", err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
return
}
if so.Fsync {
ctx = filer.WithSyncWrite(ctx)
}
if util.FullPath(r.URL.Path).IsLongerFileName(so.MaxFileNameLength) {
glog.V(1).InfolnCtx(ctx, "post", r.RequestURI, ": ", "entry name too long")
w.WriteHeader(http.StatusRequestURITooLong)
return
}
// When DiskType is empty,use filer's -disk
if so.DiskType == "" {
so.DiskType = fs.option.DiskType
}
if strings.HasPrefix(r.URL.Path, "/etc") {
so.SaveInside = true
}
if query.Has("mv.from") {
fs.move(ctx, w, r, so)
} else if query.Has("cp.from") {
fs.copy(ctx, w, r, so)
} else if isExtractZip(r) {
fs.extractZipArchive(ctx, w, r)
} else {
fs.autoChunk(ctx, w, r, contentLength, so)
}
util_http.CloseRequest(r)
}
func (fs *FilerServer) move(ctx context.Context, w http.ResponseWriter, r *http.Request, so *operation.StorageOption) {
src := r.URL.Query().Get("mv.from")
dst := r.URL.Path
glog.V(2).InfofCtx(ctx, "FilerServer.move %v to %v", src, dst)
var err error
if src, err = clearName(src); err != nil {
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
if dst, err = clearName(dst); err != nil {
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
src = strings.TrimRight(src, "/")
if src == "" {
err = fmt.Errorf("invalid source '/'")
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
srcPath := util.FullPath(src)
dstPath := util.FullPath(dst)
if dstPath.IsLongerFileName(so.MaxFileNameLength) {
err = fmt.Errorf("dst name to long")
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
srcEntry, err := fs.filer.FindEntry(ctx, srcPath)
if err != nil {
err = fmt.Errorf("failed to get src entry '%s', err: %s", src, err)
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
wormEnforced, err := fs.wormEnforcedForEntry(ctx, src)
if err != nil {
writeJsonError(w, r, http.StatusInternalServerError, err)
return
} else if wormEnforced {
// you cannot move a worm file or directory
err = fmt.Errorf("cannot move write-once entry from '%s' to '%s': %s", src, dst, constants.ErrMsgOperationNotPermitted)
writeJsonError(w, r, http.StatusForbidden, err)
return
}
oldDir, oldName := srcPath.DirAndName()
newDir, newName := dstPath.DirAndName()
newName = util.Nvl(newName, oldName)
dstEntry, err := fs.filer.FindEntry(ctx, util.FullPath(strings.TrimRight(dst, "/")))
if err != nil && err != filer_pb.ErrNotFound {
err = fmt.Errorf("failed to get dst entry '%s', err: %s", dst, err)
writeJsonError(w, r, http.StatusInternalServerError, err)
return
}
if err == nil && !dstEntry.IsDirectory() && srcEntry.IsDirectory() {
err = fmt.Errorf("move: cannot overwrite non-directory '%s' with directory '%s'", dst, src)
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
_, err = fs.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: oldDir,
OldName: oldName,
NewDirectory: newDir,
NewName: newName,
})
if err != nil {
err = fmt.Errorf("failed to move entry from '%s' to '%s', err: %s", src, dst, err)
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// curl -X DELETE http://localhost:8888/path/to
// curl -X DELETE http://localhost:8888/path/to?recursive=true
// curl -X DELETE http://localhost:8888/path/to?recursive=true&ignoreRecursiveError=true
// curl -X DELETE http://localhost:8888/path/to?recursive=true&skipChunkDeletion=true
func (fs *FilerServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
isRecursive := r.FormValue("recursive") == "true"
if !isRecursive && fs.option.recursiveDelete {
if r.FormValue("recursive") != "false" {
isRecursive = true
}
}
ignoreRecursiveError := r.FormValue("ignoreRecursiveError") == "true"
skipChunkDeletion := r.FormValue("skipChunkDeletion") == "true"
objectPath := r.URL.Path
if len(r.URL.Path) > 1 && strings.HasSuffix(objectPath, "/") {
objectPath = objectPath[0 : len(objectPath)-1]
}
wormEnforced, err := fs.wormEnforcedForEntry(context.TODO(), objectPath)
if err != nil {
writeJsonError(w, r, http.StatusInternalServerError, err)
return
} else if wormEnforced {
writeJsonError(w, r, http.StatusForbidden, errors.New(constants.ErrMsgOperationNotPermitted))
return
}
// The delete and the commit record it emits must serialize with peer
// replay of this path, same as the gRPC DeleteEntry handler.
fullPath := util.FullPath(objectPath)
if fs.entryLockTable != nil {
pathLock := fs.entryLockTable.AcquireLock("DeleteHandler", fullPath, util.ExclusiveLock)
defer fs.entryLockTable.ReleaseLock(fullPath, pathLock)
}
err = fs.filer.DeleteEntryMetaAndData(context.Background(), fullPath, isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0)
if err != nil && err != filer_pb.ErrNotFound {
glog.V(1).Infoln("deleting", objectPath, ":", err.Error())
writeJsonError(w, r, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (fs *FilerServer) detectStorageOption(ctx context.Context, requestURI, qCollection, qReplication string, ttlSeconds int32, diskType, dataCenter, rack, dataNode string) (*operation.StorageOption, error) {
rule := fs.filer.FilerConf.MatchStorageRule(requestURI)
if rule.ReadOnly {
// Name the read-only prefix so the caller knows which path is locked and why.
// MatchStorageRule leaves LocationPrefix empty when several rules merge; fall back to the request path.
prefix := rule.LocationPrefix
if prefix == "" {
prefix = requestURI
}
return nil, fmt.Errorf("%w: %s (e.g. bucket over quota)", ErrReadOnly, prefix)
}
// Use local variable instead of mutating shared rule
maxFileNameLength := rule.MaxFileNameLength
if maxFileNameLength == 0 {
maxFileNameLength = fs.filer.MaxFilenameLength
}
// required by buckets folder
bucketDefaultCollection := ""
if strings.HasPrefix(requestURI, fs.filer.DirBucketsPath+"/") {
bucketDefaultCollection = fs.filer.DetectBucket(util.FullPath(requestURI))
}
if ttlSeconds == 0 {
ttl, err := needle.ReadTTL(rule.GetTtl())
if err != nil {
glog.ErrorfCtx(ctx, "fail to parse %s ttl setting %s: %v", rule.LocationPrefix, rule.Ttl, err)
}
ttlSeconds = int32(ttl.Minutes()) * 60
}
collection := util.Nvl(qCollection, rule.Collection, bucketDefaultCollection, fs.option.Collection)
// A placement overlay steers a bound collection's new volumes onto a tier.
// It sits between the explicit request and the filer.conf rule: it overrides
// the rule but yields to a value the caller asked for outright. Only a
// steered collection contributes values; an unsteered one clears them so it
// falls straight through to the rule.
overlayDisk, overlayReplication, overlayDataCenter, overlaySteered := fs.filer.ResolvePlacement(collection)
if !overlaySteered {
overlayDisk, overlayReplication, overlayDataCenter = "", "", ""
} else {
glog.V(4).InfofCtx(ctx, "placement overlay steers collection %s: disk=%q replication=%q dataCenter=%q",
collection, overlayDisk, overlayReplication, overlayDataCenter)
}
return &operation.StorageOption{
Replication: util.Nvl(qReplication, overlayReplication, rule.Replication, fs.option.DefaultReplication),
Collection: collection,
DataCenter: util.Nvl(dataCenter, overlayDataCenter, rule.DataCenter, fs.option.DataCenter),
Rack: util.Nvl(rack, rule.Rack, fs.option.Rack),
DataNode: util.Nvl(dataNode, rule.DataNode, fs.option.DataNode),
TtlSeconds: ttlSeconds,
DiskType: util.Nvl(diskType, overlayDisk, rule.DiskType),
Fsync: rule.Fsync,
VolumeGrowthCount: rule.VolumeGrowthCount,
MaxFileNameLength: maxFileNameLength,
// A location that survives from a request parameter, a placement
// overlay, or the path rule is a contract; only a bare filer default
// is a preference that may still be widened.
EnforceLocation: dataCenter != "" || overlayDataCenter != "" || rule.DataCenter != "" ||
rack != "" || rule.Rack != "" || dataNode != "" || rule.DataNode != "",
}, nil
}
func (fs *FilerServer) detectStorageOption0(ctx context.Context, requestURI, qCollection, qReplication string, qTtl string, diskType string, fsync string, dataCenter, rack, dataNode, saveInside string) (*operation.StorageOption, error) {
ttl, err := needle.ReadTTL(qTtl)
if err != nil {
glog.ErrorfCtx(ctx, "fail to parse ttl %s: %v", qTtl, err)
}
so, err := fs.detectStorageOption(ctx, requestURI, qCollection, qReplication, int32(ttl.Minutes())*60, diskType, dataCenter, rack, dataNode)
if so != nil {
if fsync == "false" {
so.Fsync = false
} else if fsync == "true" {
so.Fsync = true
}
if saveInside == "true" {
so.SaveInside = true
} else {
so.SaveInside = false
}
}
return so, err
}