From 4e0678c04cf61a58e7575fae5d2bd892259d8efe Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH] skip orphan cars in piece metadata lookup Car.AttachmentID is ON DELETE SET NULL (for fast prep deletion with async cleanup). When a prep is deleted and re-created, the old car rows survive with attachment_id = NULL while a new row gets created for the same piece_cid. GetMetadataHandler used .First() which picks the oldest row by PK -- an orphan -- and returned 404 even though a valid row existed. findPiece already iterates past orphans; this aligns the metadata lookup with that behavior. --- service/contentprovider/http.go | 9 ++-- service/contentprovider/http_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/service/contentprovider/http.go b/service/contentprovider/http.go index 81214f036..8f742722f 100644 --- a/service/contentprovider/http.go +++ b/service/contentprovider/http.go @@ -211,17 +211,16 @@ func GetMetadataHandler(c echo.Context, db *gorm.DB) error { return c.String(http.StatusBadRequest, "failed to parse piece CID: "+err.Error()) } + // filter to rows with an attachment -- orphaned cars (attachment_id NULL + // after ON DELETE SET NULL from a prior prep) share piece_cid but have + // no metadata to serve. var car model.Car ctx := c.Request().Context() - err = db.WithContext(ctx).Where("piece_cid = ?", model.CID(pieceCid)).First(&car).Error + err = db.WithContext(ctx).Where("piece_cid = ? AND attachment_id IS NOT NULL", model.CID(pieceCid)).First(&car).Error if errors.Is(err, gorm.ErrRecordNotFound) { return c.String(http.StatusNotFound, "piece not found") } - if car.AttachmentID == nil { - return c.String(http.StatusNotFound, "piece metadata not found") - } - metadata, err := getPieceMetadata(ctx, db, car) if err != nil { return c.String(http.StatusInternalServerError, "Error: "+err.Error()) diff --git a/service/contentprovider/http_test.go b/service/contentprovider/http_test.go index 2a0d25557..d3110d5f9 100644 --- a/service/contentprovider/http_test.go +++ b/service/contentprovider/http_test.go @@ -164,6 +164,71 @@ func TestHTTPServerHandler(t *testing.T) { }) } + // orphan car (attachment_id NULL from ON DELETE SET NULL) coexists with a + // valid car for the same piece_cid. metadata must pick the valid one. + t.Run("orphan_car_skipped", func(t *testing.T) { + orphanPieceCID := cid.NewCidV1(cid.FilCommitmentUnsealed, util.Hash([]byte("orphan_test"))) + + // orphan car created first (lower PK), no associations + err := db.Create(&model.Car{ + PieceCID: model.CID(orphanPieceCID), + PieceSize: 128, + PieceType: model.DataPiece, + RootCID: model.CID(testutil.TestCid), + }).Error + require.NoError(t, err) + + // valid car with attachment, created second + prep := &model.Preparation{Name: "orphan_prep"} + require.NoError(t, db.Create(prep).Error) + storage := &model.Storage{Name: "orphan_storage", Type: "local"} + require.NoError(t, db.Create(storage).Error) + attachment := &model.SourceAttachment{ + PreparationID: prep.ID, + StorageID: storage.ID, + } + require.NoError(t, db.Create(attachment).Error) + require.NoError(t, db.Create(&model.Car{ + PieceCID: model.CID(orphanPieceCID), + PieceSize: 128, + PreparationID: &prep.ID, + AttachmentID: &attachment.ID, + PieceType: model.DataPiece, + RootCID: model.CID(testutil.TestCid), + }).Error) + + req := httptest.NewRequest(http.MethodGet, "/piece/metadata/:id", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/piece/metadata/:id") + c.SetParamNames("id") + c.SetParamValues(orphanPieceCID.String()) + err = s.getMetadataHandler(c) + require.NoError(t, err) + require.Equal(t, http.StatusOK, rec.Code) + }) + + // only an orphan exists -- 404 + t.Run("orphan_only_returns_404", func(t *testing.T) { + onlyOrphanCID := cid.NewCidV1(cid.FilCommitmentUnsealed, util.Hash([]byte("only_orphan"))) + require.NoError(t, db.Create(&model.Car{ + PieceCID: model.CID(onlyOrphanCID), + PieceSize: 128, + PieceType: model.DataPiece, + RootCID: model.CID(testutil.TestCid), + }).Error) + + req := httptest.NewRequest(http.MethodGet, "/piece/metadata/:id", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/piece/metadata/:id") + c.SetParamNames("id") + c.SetParamValues(onlyOrphanCID.String()) + err := s.getMetadataHandler(c) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, rec.Code) + }) + // Test DAG piece type t.Run("dag_piece_metadata", func(t *testing.T) { preparation := &model.Preparation{Name: "test_prep_dag"}