diff --git a/api/object.go b/api/object.go index 4b1993341..36cea9db8 100644 --- a/api/object.go +++ b/api/object.go @@ -92,6 +92,7 @@ type ( // HeadObjectResponse is the response type for the HEAD /worker/object endpoint. HeadObjectResponse struct { ContentType string `json:"contentType"` + Etag string `json:"eTag"` LastModified string `json:"lastModified"` Range *DownloadRange `json:"range,omitempty"` Size int64 `json:"size"` @@ -212,7 +213,8 @@ type ( } HeadObjectOptions struct { - Range DownloadRange + IgnoreDelim bool + Range DownloadRange } DownloadObjectOptions struct { @@ -310,6 +312,12 @@ func (opts DeleteObjectOptions) Apply(values url.Values) { } } +func (opts HeadObjectOptions) Apply(values url.Values) { + if opts.IgnoreDelim { + values.Set("ignoreDelim", "true") + } +} + func (opts HeadObjectOptions) ApplyHeaders(h http.Header) { if opts.Range != (DownloadRange{}) { if opts.Range.Length == -1 { diff --git a/internal/test/e2e/metadata_test.go b/internal/test/e2e/metadata_test.go index d11f6ba4e..af924f847 100644 --- a/internal/test/e2e/metadata_test.go +++ b/internal/test/e2e/metadata_test.go @@ -55,6 +55,8 @@ func TestObjectMetadata(t *testing.T) { } if !reflect.DeepEqual(gor.Metadata, opts.Metadata) { t.Fatal("metadata mismatch", gor.Metadata) + } else if gor.Etag == "" { + t.Fatal("missing etag") } // perform a HEAD request and assert the headers are all present @@ -63,6 +65,7 @@ func TestObjectMetadata(t *testing.T) { t.Fatal(err) } else if !reflect.DeepEqual(hor, &api.HeadObjectResponse{ ContentType: or.Object.ContentType(), + Etag: gor.Etag, LastModified: or.Object.LastModified(), Range: &api.DownloadRange{Offset: 1, Length: 1, Size: int64(len(data))}, Size: int64(len(data)), diff --git a/internal/test/e2e/s3_test.go b/internal/test/e2e/s3_test.go index c8c6bb334..cb4c7cd60 100644 --- a/internal/test/e2e/s3_test.go +++ b/internal/test/e2e/s3_test.go @@ -3,6 +3,8 @@ package e2e import ( "bytes" "context" + "crypto/md5" + "encoding/hex" "errors" "fmt" "io" @@ -72,8 +74,12 @@ func TestS3Basic(t *testing.T) { // add object to the bucket data := frand.Bytes(10) + etag := md5.Sum(data) uploadInfo, err := s3.PutObject(context.Background(), bucket, objPath, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{}) tt.OK(err) + if uploadInfo.ETag != hex.EncodeToString(etag[:]) { + t.Fatalf("expected ETag %v, got %v", hex.EncodeToString(etag[:]), uploadInfo.ETag) + } busObject, err := cluster.Bus.Object(context.Background(), bucket, objPath, api.GetObjectOptions{}) tt.OK(err) if busObject.Object == nil { @@ -92,6 +98,10 @@ func TestS3Basic(t *testing.T) { t.Fatal(err) } else if !bytes.Equal(b, data) { t.Fatal("data mismatch") + } else if info, err := obj.Stat(); err != nil { + t.Fatal(err) + } else if info.ETag != uploadInfo.ETag { + t.Fatal("unexpected ETag:", info.ETag, uploadInfo.ETag) } // stat object @@ -99,6 +109,8 @@ func TestS3Basic(t *testing.T) { tt.OK(err) if info.Size != int64(len(data)) { t.Fatal("size mismatch") + } else if info.ETag != uploadInfo.ETag { + t.Fatal("unexpected ETag:", info.ETag) } // add another bucket @@ -487,6 +499,13 @@ func TestS3List(t *testing.T) { if !cmp.Equal(test.want, got) { t.Errorf("test %d: unexpected response, want %v got %v", i, test.want, got) } + for _, obj := range result.Contents { + if obj.ETag == "" { + t.Fatal("expected non-empty ETag") + } else if obj.LastModified.IsZero() { + t.Fatal("expected non-zero LastModified") + } + } } } @@ -580,12 +599,28 @@ func TestS3MultipartUploads(t *testing.T) { } // Download object + expectedData := []byte("helloworld!") downloadedObj, err := s3.GetObject(context.Background(), "multipart", "foo", minio.GetObjectOptions{}) tt.OK(err) if data, err := io.ReadAll(downloadedObj); err != nil { t.Fatal(err) - } else if !bytes.Equal(data, []byte("helloworld!")) { + } else if !bytes.Equal(data, expectedData) { t.Fatal("unexpected data:", string(data)) + } else if info, err := downloadedObj.Stat(); err != nil { + t.Fatal(err) + } else if info.ETag != ui.ETag { + t.Fatal("unexpected ETag:", info.ETag) + } else if info.Size != int64(len(expectedData)) { + t.Fatal("unexpected size:", info.Size) + } + + // Stat object + if info, err := s3.StatObject(context.Background(), "multipart", "foo", minio.StatObjectOptions{}); err != nil { + t.Fatal(err) + } else if info.ETag != ui.ETag { + t.Fatal("unexpected ETag:", info.ETag) + } else if info.Size != int64(len(expectedData)) { + t.Fatal("unexpected size:", info.Size) } // Download again with range request. diff --git a/object/object.go b/object/object.go index 965ebce2a..e8243fac1 100644 --- a/object/object.go +++ b/object/object.go @@ -3,7 +3,6 @@ package object import ( "bytes" "crypto/cipher" - "crypto/md5" "encoding/binary" "encoding/hex" "fmt" @@ -146,22 +145,6 @@ func (o Object) Contracts() map[types.PublicKey]map[types.FileContractID]struct{ return usedContracts } -func (o *Object) ComputeETag() string { - // calculate the eTag using the precomputed sector roots to avoid having to - // hash the entire object again. - h := md5.New() - b := make([]byte, 8) - for _, slab := range o.Slabs { - binary.LittleEndian.PutUint32(b[:4], slab.Offset) - binary.LittleEndian.PutUint32(b[4:], slab.Length) - h.Write(b) - for _, shard := range slab.Shards { - h.Write(shard.Root[:]) - } - } - return string(hex.EncodeToString(h.Sum(nil))) -} - // TotalSize returns the total size of the object. func (o Object) TotalSize() int64 { var n int64 diff --git a/s3/backend.go b/s3/backend.go index 7b5ea74f9..5261bd5f7 100644 --- a/s3/backend.go +++ b/s3/backend.go @@ -3,6 +3,7 @@ package s3 import ( "bytes" "context" + "encoding/hex" "fmt" "io" "strings" @@ -268,7 +269,14 @@ func (s *s3) GetObject(ctx context.Context, bucketName, objectName string, range res.Metadata["Content-Type"] = res.ContentType res.Metadata["Last-Modified"] = res.LastModified + // etag to bytes + etag, err := hex.DecodeString(res.Etag) + if err != nil { + return nil, gofakes3.ErrorMessage(gofakes3.ErrInternal, err.Error()) + } + return &gofakes3.Object{ + Hash: etag, Name: gofakes3.URLEncode(objectName), Metadata: res.Metadata, Size: res.Size, @@ -287,9 +295,8 @@ func (s *s3) GetObject(ctx context.Context, bucketName, objectName string, range // HeadObject should return a NotFound() error if the object does not // exist. func (s *s3) HeadObject(ctx context.Context, bucketName, objectName string) (*gofakes3.Object, error) { - res, err := s.b.Object(ctx, bucketName, objectName, api.GetObjectOptions{ - IgnoreDelim: true, - OnlyMetadata: true, + res, err := s.w.HeadObject(ctx, bucketName, objectName, api.HeadObjectOptions{ + IgnoreDelim: true, }) if err != nil && strings.Contains(err.Error(), api.ErrObjectNotFound.Error()) { return nil, gofakes3.KeyNotFound(objectName) @@ -299,18 +306,25 @@ func (s *s3) HeadObject(ctx context.Context, bucketName, objectName string) (*go // set user metadata metadata := make(map[string]string) - for k, v := range res.Object.Metadata { + for k, v := range res.Metadata { metadata[amazonMetadataPrefix+k] = v } // decorate metadata - metadata["Content-Type"] = res.Object.MimeType - metadata["Last-Modified"] = res.Object.LastModified() + metadata["Content-Type"] = res.ContentType + metadata["Last-Modified"] = res.LastModified + + // etag to bytes + hash, err := hex.DecodeString(res.Etag) + if err != nil { + return nil, gofakes3.ErrorMessage(gofakes3.ErrInternal, err.Error()) + } return &gofakes3.Object{ + Hash: hash, Name: gofakes3.URLEncode(objectName), Metadata: metadata, - Size: res.Object.Size, + Size: res.Size, Contents: io.NopCloser(bytes.NewReader(nil)), }, nil } diff --git a/s3/s3.go b/s3/s3.go index 95c2e98e6..0ac1dbd49 100644 --- a/s3/s3.go +++ b/s3/s3.go @@ -48,6 +48,7 @@ type bus interface { type worker interface { GetObject(ctx context.Context, bucket, path string, opts api.DownloadObjectOptions) (*api.GetObjectResponse, error) + HeadObject(ctx context.Context, bucket, path string, opts api.HeadObjectOptions) (*api.HeadObjectResponse, error) UploadObject(ctx context.Context, r io.Reader, bucket, path string, opts api.UploadObjectOptions) (*api.UploadObjectResponse, error) UploadMultipartUploadPart(ctx context.Context, r io.Reader, bucket, path, uploadID string, partNumber int, opts api.UploadMultipartUploadPartOptions) (*api.UploadMultipartUploadPartResponse, error) } diff --git a/stores/metadata.go b/stores/metadata.go index 529d7ec89..0733ad567 100644 --- a/stores/metadata.go +++ b/stores/metadata.go @@ -1474,7 +1474,7 @@ func (s *SQLStore) RenameObject(ctx context.Context, bucket, keyOld, keyNew stri if force { // delete potentially existing object at destination if _, err := s.deleteObject(tx, bucket, keyNew); err != nil { - return err + return fmt.Errorf("RenameObject: failed to delete object: %w", err) } } tx = tx.Exec(`UPDATE objects SET object_id = ? WHERE object_id = ? AND ?`, keyNew, keyOld, sqlWhereBucket("objects", bucket)) @@ -1539,6 +1539,13 @@ func (s *SQLStore) AddPartialSlab(ctx context.Context, data []byte, minShards, t func (s *SQLStore) CopyObject(ctx context.Context, srcBucket, dstBucket, srcPath, dstPath, mimeType string, metadata api.ObjectUserMetadata) (om api.ObjectMetadata, err error) { err = s.retryTransaction(func(tx *gorm.DB) error { + if srcBucket != dstBucket || srcPath != dstPath { + _, err = s.deleteObject(tx, dstBucket, dstPath) + if err != nil { + return fmt.Errorf("CopyObject: failed to delete object: %w", err) + } + } + var srcObj dbObject err = tx.Where("objects.object_id = ? AND DBBucket.name = ?", srcPath, srcBucket). Joins("DBBucket"). @@ -1565,10 +1572,6 @@ func (s *SQLStore) CopyObject(ctx context.Context, srcBucket, dstBucket, srcPath } return tx.Save(&srcObj).Error } - _, err = s.deleteObject(tx, dstBucket, dstPath) - if err != nil { - return fmt.Errorf("failed to delete object: %w", err) - } var srcSlices []dbSlice err = tx.Where("db_object_id = ?", srcObj.ID). @@ -1708,12 +1711,6 @@ func (s *SQLStore) UpdateObject(ctx context.Context, bucket, path, contractSet, // UpdateObject is ACID. return s.retryTransaction(func(tx *gorm.DB) error { - // Fetch contract set. - var cs dbContractSet - if err := tx.Take(&cs, "name = ?", contractSet).Error; err != nil { - return fmt.Errorf("contract set %v not found: %w", contractSet, err) - } - // Try to delete. We want to get rid of the object and its slices if it // exists. // @@ -1726,7 +1723,7 @@ func (s *SQLStore) UpdateObject(ctx context.Context, bucket, path, contractSet, // object's metadata before trying to recreate it _, err := s.deleteObject(tx, bucket, path) if err != nil { - return fmt.Errorf("failed to delete object: %w", err) + return fmt.Errorf("UpdateObject: failed to delete object: %w", err) } // Insert a new object. @@ -1734,14 +1731,16 @@ func (s *SQLStore) UpdateObject(ctx context.Context, bucket, path, contractSet, if err != nil { return fmt.Errorf("failed to marshal object key: %w", err) } + // fetch bucket id var bucketID uint - err = tx.Table("(SELECT id from buckets WHERE buckets.name = ?) bucket_id", bucket). + err = s.db.Table("(SELECT id from buckets WHERE buckets.name = ?) bucket_id", bucket). Take(&bucketID).Error if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("bucket %v not found: %w", bucket, api.ErrBucketNotFound) } else if err != nil { return fmt.Errorf("failed to fetch bucket id: %w", err) } + obj := dbObject{ DBBucketID: bucketID, ObjectID: path, @@ -1755,6 +1754,12 @@ func (s *SQLStore) UpdateObject(ctx context.Context, bucket, path, contractSet, return fmt.Errorf("failed to create object: %w", err) } + // Fetch contract set. + var cs dbContractSet + if err := tx.Take(&cs, "name = ?", contractSet).Error; err != nil { + return fmt.Errorf("contract set %v not found: %w", contractSet, err) + } + // Fetch the used contracts. contracts, err := fetchUsedContracts(tx, usedContracts) if err != nil { @@ -1780,7 +1785,10 @@ func (s *SQLStore) RemoveObject(ctx context.Context, bucket, key string) error { var err error err = s.retryTransaction(func(tx *gorm.DB) error { rowsAffected, err = s.deleteObject(tx, bucket, key) - return err + if err != nil { + return fmt.Errorf("RemoveObject: failed to delete object: %w", err) + } + return nil }) if err != nil { return err @@ -2722,7 +2730,21 @@ AND slabs.db_buffered_slab_id IS NULL // without an obect after the deletion. That means in case of packed uploads, // the slab is only deleted when no more objects point to it. func (s *SQLStore) deleteObject(tx *gorm.DB, bucket string, path string) (int64, error) { - tx = tx.Where("object_id = ? AND ?", path, sqlWhereBucket("objects", bucket)). + // check if the object exists first to avoid unnecessary locking for the + // common case + var objID uint + resp := tx.Model(&dbObject{}). + Where("object_id = ? AND ?", path, sqlWhereBucket("objects", bucket)). + Select("id"). + Limit(1). + Scan(&objID) + if err := resp.Error; err != nil { + return 0, err + } else if resp.RowsAffected == 0 { + return 0, nil + } + + tx = tx.Where("id", objID). Delete(&dbObject{}) if tx.Error != nil { return 0, tx.Error @@ -2872,7 +2894,7 @@ func (s *SQLStore) ListObjects(ctx context.Context, bucket, prefix, sortBy, sort } var rows []rawObjectMetadata if err := s.db. - Select("o.object_id as Name, o.size as Size, o.health as Health, o.mime_type as mimeType, o.created_at as ModTime"). + Select("o.object_id as Name, o.size as Size, o.health as Health, o.mime_type as MimeType, o.created_at as ModTime, o.etag as ETag"). Model(&dbObject{}). Table("objects o"). Joins("INNER JOIN buckets b ON o.db_bucket_id = b.id"). diff --git a/stores/metadata_test.go b/stores/metadata_test.go index f5461147c..55bf93573 100644 --- a/stores/metadata_test.go +++ b/stores/metadata_test.go @@ -10,6 +10,7 @@ import ( "reflect" "sort" "strings" + "sync" "testing" "time" @@ -3491,6 +3492,13 @@ func TestListObjects(t *testing.T) { {"/foo", "size", "ASC", "", []api.ObjectMetadata{{Name: "/foo/bar", Size: 1, Health: 1}, {Name: "/foo/bat", Size: 2, Health: 1}, {Name: "/foo/baz/quux", Size: 3, Health: .75}, {Name: "/foo/baz/quuz", Size: 4, Health: .5}}}, {"/foo", "size", "DESC", "", []api.ObjectMetadata{{Name: "/foo/baz/quuz", Size: 4, Health: .5}, {Name: "/foo/baz/quux", Size: 3, Health: .75}, {Name: "/foo/bat", Size: 2, Health: 1}, {Name: "/foo/bar", Size: 1, Health: 1}}}, } + // set common fields + for i := range tests { + for j := range tests[i].want { + tests[i].want[j].ETag = testETag + tests[i].want[j].MimeType = testMimeType + } + } for _, test := range tests { res, err := ss.ListObjects(ctx, api.DefaultBucketName, test.prefix, test.sortBy, test.sortDir, "", -1) if err != nil { @@ -4545,3 +4553,104 @@ func TestTypeCurrency(t *testing.T) { } } } + +// TestUpdateObjectParallel calls UpdateObject from multiple threads in parallel +// while retries are disabled to make sure calling the same method from multiple +// threads won't cause deadlocks. +// +// NOTE: This test only covers the optimistic case of inserting objects without +// overwriting them. As soon as combining deletions and insertions within the +// same transaction, deadlocks become more likely due to the gap locks MySQL +// uses. +func TestUpdateObjectParallel(t *testing.T) { + cfg := defaultTestSQLStoreConfig + + dbURI, _, _, _ := DBConfigFromEnv() + if dbURI == "" { + // it's pretty much impossile to optimise for both sqlite and mysql at + // the same time so we skip this test for SQLite for now + // TODO: once we moved away from gorm and implement separate interfaces + // for SQLite and MySQL, we have more control over the used queries and + // can revisit this + t.SkipNow() + } + ss := newTestSQLStore(t, cfg) + ss.retryTransactionIntervals = []time.Duration{0} // don't retry + defer ss.Close() + + // create 2 hosts + hks, err := ss.addTestHosts(2) + if err != nil { + t.Fatal(err) + } + hk1, hk2 := hks[0], hks[1] + + // create 2 contracts + fcids, _, err := ss.addTestContracts(hks) + if err != nil { + t.Fatal(err) + } + fcid1, fcid2 := fcids[0], fcids[1] + + c := make(chan string) + ctx, cancel := context.WithCancel(context.Background()) + work := func() { + t.Helper() + defer cancel() + for name := range c { + // create an object + obj := object.Object{ + Key: object.GenerateEncryptionKey(), + Slabs: []object.SlabSlice{ + { + Slab: object.Slab{ + Health: 1.0, + Key: object.GenerateEncryptionKey(), + MinShards: 1, + Shards: newTestShards(hk1, fcid1, frand.Entropy256()), + }, + Offset: 10, + Length: 100, + }, + { + Slab: object.Slab{ + Health: 1.0, + Key: object.GenerateEncryptionKey(), + MinShards: 2, + Shards: newTestShards(hk2, fcid2, frand.Entropy256()), + }, + Offset: 20, + Length: 200, + }, + }, + } + + // update the object + if err := ss.UpdateObject(context.Background(), api.DefaultBucketName, name, testContractSet, testETag, testMimeType, testMetadata, obj); err != nil { + t.Error(err) + return + } + } + } + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + work() + wg.Done() + }() + } + + // create 1000 objects and then overwrite them + for i := 0; i < 1000; i++ { + select { + case c <- fmt.Sprintf("object-%d", i): + case <-ctx.Done(): + return + } + } + + close(c) + wg.Wait() +} diff --git a/stores/multipart.go b/stores/multipart.go index be3333077..76c30c734 100644 --- a/stores/multipart.go +++ b/stores/multipart.go @@ -327,6 +327,12 @@ func (s *SQLStore) CompleteMultipartUpload(ctx context.Context, bucket, path str } var eTag string err = s.retryTransaction(func(tx *gorm.DB) error { + // Delete potentially existing object. + _, err := s.deleteObject(tx, bucket, path) + if err != nil { + return fmt.Errorf("failed to delete object: %w", err) + } + // Find multipart upload. var mu dbMultipartUpload err = tx.Where("upload_id = ?", uploadID). @@ -347,12 +353,6 @@ func (s *SQLStore) CompleteMultipartUpload(ctx context.Context, bucket, path str return fmt.Errorf("bucket name mismatch: %v != %v: %w", mu.DBBucket.Name, bucket, api.ErrBucketNotFound) } - // Delete potentially existing object. - _, err := s.deleteObject(tx, bucket, path) - if err != nil { - return fmt.Errorf("failed to delete object: %w", err) - } - // Sort the parts. sort.Slice(mu.Parts, func(i, j int) bool { return mu.Parts[i].PartNumber < mu.Parts[j].PartNumber diff --git a/stores/sql_test.go b/stores/sql_test.go index 776e3e10e..17c296075 100644 --- a/stores/sql_test.go +++ b/stores/sql_test.go @@ -107,8 +107,8 @@ func newTestSQLStore(t *testing.T, cfg testSQLStoreConfig) *testSQLStore { conn = NewMySQLConnection(dbUser, dbPassword, dbURI, dbName) connMetrics = NewMySQLConnection(dbUser, dbPassword, dbURI, dbMetricsName) } else if cfg.persistent { - conn = NewSQLiteConnection(filepath.Join(cfg.dir, "db.sqlite")) - connMetrics = NewSQLiteConnection(filepath.Join(cfg.dir, "metrics.sqlite")) + conn = NewSQLiteConnection(filepath.Join(dir, "db.sqlite")) + connMetrics = NewSQLiteConnection(filepath.Join(dir, "metrics.sqlite")) } else { conn = NewEphemeralSQLiteConnection(dbName) connMetrics = NewEphemeralSQLiteConnection(dbMetricsName) diff --git a/worker/client/client.go b/worker/client/client.go index 410e4c66e..6ef70f338 100644 --- a/worker/client/client.go +++ b/worker/client/client.go @@ -81,12 +81,10 @@ func (c *Client) DownloadStats() (resp api.DownloadStatsResponse, err error) { func (c *Client) HeadObject(ctx context.Context, bucket, path string, opts api.HeadObjectOptions) (*api.HeadObjectResponse, error) { c.c.Custom("HEAD", fmt.Sprintf("/objects/%s", path), nil, nil) - if strings.HasSuffix(path, "/") { - return nil, errors.New("the given path is a directory, HEAD can only be performed on objects") - } - values := url.Values{} values.Set("bucket", url.QueryEscape(bucket)) + opts.Apply(values) + path = api.ObjectPathEscape(path) path += "?" + values.Encode() // TODO: support HEAD in jape client @@ -325,6 +323,7 @@ func parseObjectResponseHeaders(header http.Header) (api.HeadObjectResponse, err return api.HeadObjectResponse{ ContentType: header.Get("Content-Type"), + Etag: trimEtag(header.Get("ETag")), LastModified: header.Get("Last-Modified"), Range: r, Size: size, @@ -347,3 +346,8 @@ func sizeFromSeeker(r io.Reader) (int64, error) { } return size, nil } + +func trimEtag(etag string) string { + etag = strings.TrimPrefix(etag, "\"") + return strings.TrimSuffix(etag, "\"") +} diff --git a/worker/upload.go b/worker/upload.go index c5e86a166..ab84e2b37 100644 --- a/worker/upload.go +++ b/worker/upload.go @@ -2,6 +2,8 @@ package worker import ( "context" + "crypto/md5" + "encoding/hex" "errors" "fmt" "io" @@ -390,6 +392,11 @@ func (mgr *uploadManager) Upload(ctx context.Context, r io.Reader, contracts []a // create the object o := object.NewObject(up.ec) + // create the md5 hasher for the etag + // NOTE: we use md5 since it's s3 compatible and clients expect it to be md5 + hasher := md5.New() + r = io.TeeReader(r, hasher) + // create the cipher reader cr, err := o.Encrypt(r, up.encryptionOffset) if err != nil { @@ -520,7 +527,7 @@ func (mgr *uploadManager) Upload(ctx context.Context, r io.Reader, contracts []a } // compute etag - eTag = o.ComputeETag() + eTag = hex.EncodeToString(hasher.Sum(nil)) // add partial slabs if len(partialSlab) > 0 { diff --git a/worker/worker.go b/worker/worker.go index fb645840d..dab8ef30a 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -861,19 +861,24 @@ func (w *worker) objectsHandlerHEAD(jc jape.Context) { if jc.DecodeForm("bucket", &bucket) != nil { return } + var ignoreDelim bool + if jc.DecodeForm("ignoreDelim", &ignoreDelim) != nil { + return + } // parse path path := jc.PathParam("path") - if path == "" || strings.HasSuffix(path, "/") { + if !ignoreDelim && (path == "" || strings.HasSuffix(path, "/")) { jc.Error(errors.New("HEAD requests can only be performed on objects, not directories"), http.StatusBadRequest) return } // fetch object metadata res, err := w.bus.Object(jc.Request.Context(), bucket, path, api.GetObjectOptions{ + IgnoreDelim: ignoreDelim, OnlyMetadata: true, }) - if errors.Is(err, api.ErrObjectNotFound) { + if err != nil && strings.Contains(err.Error(), api.ErrObjectNotFound.Error()) { jc.Error(err, http.StatusNotFound) return } else if err != nil { @@ -1099,7 +1104,7 @@ func (w *worker) objectsHandlerPUT(jc jape.Context) { if err := jc.Check("couldn't upload object", err); err != nil { if err != nil { w.logger.Error(err) - if !errors.Is(err, ErrShuttingDown) && !errors.Is(err, errUploadInterrupted) { + if !errors.Is(err, ErrShuttingDown) && !errors.Is(err, errUploadInterrupted) && !errors.Is(err, context.Canceled) { w.registerAlert(newUploadFailedAlert(bucket, path, up.ContractSet, mimeType, rs.MinShards, rs.TotalShards, len(contracts), up.UploadPacking, false, err)) } }