filer: add structured error codes to CreateEntryResponse (#8767)

* filer: add FilerError enum and error_code field to CreateEntryResponse

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This fixes the S3 compatibility test failures where creating overlapping
keys (e.g. "foo/bar" then "foo/bar/xyzzy") returned ExistingObjectIsFile.
This commit is contained in:
Chris Lu
2026-03-24 17:08:22 -07:00
committed by GitHub
parent 152884eff2
commit 0b3867dca3
11 changed files with 376 additions and 204 deletions

View File

@@ -51,7 +51,7 @@ func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, me
if createErr != nil {
return fmt.Errorf("failed to create %s: %w", errorContext, createErr)
}
if resp.Error != "" && !strings.Contains(resp.Error, "exist") {
if resp.ErrorCode != filer_pb.FilerError_OK && resp.ErrorCode != filer_pb.FilerError_ENTRY_ALREADY_EXISTS {
return fmt.Errorf("failed to create %s: %s", errorContext, resp.Error)
}
return nil
@@ -104,8 +104,11 @@ func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, me
if err != nil {
return fmt.Errorf("failed to write metadata file: %w", err)
}
if resp.Error != "" {
return fmt.Errorf("failed to write metadata file: %s", resp.Error)
if resp.ErrorCode != filer_pb.FilerError_OK {
if sentinel := filer_pb.FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
return fmt.Errorf("failed to write metadata file: %w", sentinel)
}
return fmt.Errorf("failed to write metadata file: code=%v %s", resp.ErrorCode, resp.Error)
}
return nil
})

View File

@@ -198,7 +198,7 @@ func (s *Server) writeStageCreateMarker(ctx context.Context, bucketName string,
if createErr != nil {
return fmt.Errorf("failed to create %s: %w", errorContext, createErr)
}
if resp.Error != "" && !strings.Contains(resp.Error, "exist") {
if resp.ErrorCode != filer_pb.FilerError_OK && resp.ErrorCode != filer_pb.FilerError_ENTRY_ALREADY_EXISTS {
return fmt.Errorf("failed to create %s: %s", errorContext, resp.Error)
}
return nil
@@ -236,8 +236,11 @@ func (s *Server) writeStageCreateMarker(ctx context.Context, bucketName string,
if createErr != nil {
return createErr
}
if resp.Error != "" {
return errors.New(resp.Error)
if resp.ErrorCode != filer_pb.FilerError_OK {
if sentinel := filer_pb.FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
return fmt.Errorf("create stage marker: %w", sentinel)
}
return fmt.Errorf("create stage marker: code=%v %s", resp.ErrorCode, resp.Error)
}
return nil
})

View File

@@ -15,7 +15,6 @@ import (
mathrand "math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -359,8 +358,8 @@ func (km *SSES3KeyManager) generateAndSaveSuperKeyToFiler() error {
// Set appropriate permissions for the directory
entry.Attributes.FileMode = uint32(0700 | os.ModeDir)
}); err != nil {
// Only ignore "file exists" errors.
if !strings.Contains(err.Error(), "file exists") {
// Only ignore "already exists" errors.
if !errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
return fmt.Errorf("failed to create KEK directory %s: %w", SSES3KEKDirectory, err)
}
glog.V(3).Infof("Parent directory %s already exists, continuing.", SSES3KEKDirectory)

View File

@@ -793,21 +793,25 @@ func filerErrorToS3Error(err error) s3err.ErrorCode {
return s3err.ErrNone
}
errString := err.Error()
// Filer sentinel errors — matched via errors.Is() after crossing gRPC boundary
switch {
case errors.Is(err, filer_pb.ErrEntryNameTooLong):
return s3err.ErrKeyTooLongError
case errors.Is(err, filer_pb.ErrParentIsFile), errors.Is(err, filer_pb.ErrExistingIsFile):
return s3err.ErrExistingObjectIsFile
case errors.Is(err, filer_pb.ErrExistingIsDirectory):
return s3err.ErrExistingObjectIsDirectory
case errors.Is(err, weed_server.ErrReadOnly):
return s3err.ErrAccessDenied
}
// Non-filer errors that don't go through CreateEntryResponse — string matching required
errString := err.Error()
switch {
case errString == constants.ErrMsgBadDigest:
return s3err.ErrBadDigest
case errors.Is(err, weed_server.ErrReadOnly):
return s3err.ErrAccessDenied
case strings.Contains(errString, "context canceled") || strings.Contains(errString, "code = Canceled"):
return s3err.ErrInvalidRequest
case strings.Contains(errString, constants.ErrMsgExistingPrefix) && strings.HasSuffix(errString, constants.ErrMsgIsADirectory):
return s3err.ErrExistingObjectIsDirectory
case strings.HasSuffix(errString, constants.ErrMsgIsAFile):
return s3err.ErrExistingObjectIsFile
case strings.Contains(errString, constants.ErrMsgEntryNameTooLong):
return s3err.ErrKeyTooLongError
default:
return s3err.ErrInternalError
}

View File

@@ -10,6 +10,7 @@ import (
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
@@ -53,28 +54,28 @@ func TestFilerErrorToS3Error(t *testing.T) {
expectedErr: s3err.ErrInvalidRequest,
},
{
name: "Directory exists error",
err: errors.New("existing /path/to/file is a directory"),
name: "Directory exists error (sentinel)",
err: fmt.Errorf("CreateEntry /path: %w", filer_pb.ErrExistingIsDirectory),
expectedErr: s3err.ErrExistingObjectIsDirectory,
},
{
name: "Directory exists error (CreateEntry-wrapped)",
err: errors.New("CreateEntry: existing /path/to/file is a directory"),
expectedErr: s3err.ErrExistingObjectIsDirectory,
},
{
name: "File exists error",
err: errors.New("/path/to/file is a file"),
name: "Parent is file error (sentinel)",
err: fmt.Errorf("CreateEntry /path: %w", filer_pb.ErrParentIsFile),
expectedErr: s3err.ErrExistingObjectIsFile,
},
{
name: "Entry name too long error",
err: errors.New("CreateEntry: entry name too long"),
name: "Existing is file error (sentinel)",
err: fmt.Errorf("CreateEntry /path: %w", filer_pb.ErrExistingIsFile),
expectedErr: s3err.ErrExistingObjectIsFile,
},
{
name: "Entry name too long (sentinel)",
err: fmt.Errorf("CreateEntry: %w", filer_pb.ErrEntryNameTooLong),
expectedErr: s3err.ErrKeyTooLongError,
},
{
name: "Entry name too long error (unwrapped)",
err: errors.New("entry name too long"),
name: "Entry name too long (bare sentinel)",
err: filer_pb.ErrEntryNameTooLong,
expectedErr: s3err.ErrKeyTooLongError,
},
{