test(blobs): run the blob contract on MinIO, which lists unfinished uploads by key
- commit
- 6abe3907906cf6711d2b5375c104fd25e0af5c2b
- author
- brice · · 2026-10-21 16:12 UTC
- committer
- brice · · 2026-10-21 17:53 UTC
- parent
- 27d9aa4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +35 -5
M docs/storage.md +3 -0
| @@ -51,6 +51,9 @@ Downloads are relayed through ermite with repository authorization and Range | |||
| 51 | 51 | support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts. | |
| 52 | 52 | The daily sweep removes unreferenced blobs and unfinished uploads older than | |
| 53 | 53 | 24 hours. Release metadata with a missing blob is removed only after two sweeps. | |
| 54 | + | MinIO lists unfinished uploads only for an exact object key, so the sweep does | |
| 55 | + | not see them there; MinIO removes them itself (`stale_uploads_expiry`, 24 hours | |
| 56 | + | by default). | |
| 54 | 57 | ||
| 55 | 58 | ## Backend changes | |
| 56 | 59 | ||
| @@ -51,6 +51,9 @@ Downloads are relayed through ermite with repository authorization and Range | |||||
| 51 | support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts. | 51 | support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts. | ||
| 52 | The daily sweep removes unreferenced blobs and unfinished uploads older than | 52 | The daily sweep removes unreferenced blobs and unfinished uploads older than | ||
| 53 | 24 hours. Release metadata with a missing blob is removed only after two sweeps. | 53 | 24 hours. Release metadata with a missing blob is removed only after two sweeps. | ||
| 54 | + | MinIO lists unfinished uploads only for an exact object key, so the sweep does | |||
| 55 | + | not see them there; MinIO removes them itself (`stale_uploads_expiry`, 24 hours | |||
| 56 | + | by default). | |||
| 54 | 57 | ||||
| 55 | ## Backend changes | 58 | ## Backend changes | ||
| 56 | 59 | ||||
M internal/adapters/blobs3/integration_test.go +6 -1
| @@ -23,6 +23,11 @@ func TestIntegration_Contract(t *testing.T) { | |||
| 23 | 23 | if region == "" { | |
| 24 | 24 | region = "us-east-1" | |
| 25 | 25 | } | |
| 26 | + | var opts []blobtest.Option | |
| 27 | + | // MinIO lists unfinished uploads by whole key only. | |
| 28 | + | if get("EXACT_KEY_UPLOAD_LIST") == "1" { | |
| 29 | + | opts = append(opts, blobtest.ExactKeyUploadList()) | |
| 30 | + | } | |
| 26 | 31 | blobtest.Run(t, func(t *testing.T) ports.BlobStore { | |
| 27 | 32 | b := make([]byte, 16) | |
| 28 | 33 | _, e := rand.Read(b) | |
| @@ -35,5 +40,5 @@ func TestIntegration_Contract(t *testing.T) { | |||
| 35 | 40 | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) })) | |
| 36 | 41 | }) | |
| 37 | 42 | return s | |
| 38 | - | }) | |
| 43 | + | }, opts...) | |
| 39 | 44 | } | |
| @@ -23,6 +23,11 @@ func TestIntegration_Contract(t *testing.T) { | |||||
| 23 | if region == "" { | 23 | if region == "" { | ||
| 24 | region = "us-east-1" | 24 | region = "us-east-1" | ||
| 25 | } | 25 | } | ||
| 26 | + | var opts []blobtest.Option | |||
| 27 | + | // MinIO lists unfinished uploads by whole key only. | |||
| 28 | + | if get("EXACT_KEY_UPLOAD_LIST") == "1" { | |||
| 29 | + | opts = append(opts, blobtest.ExactKeyUploadList()) | |||
| 30 | + | } | |||
| 26 | blobtest.Run(t, func(t *testing.T) ports.BlobStore { | 31 | blobtest.Run(t, func(t *testing.T) ports.BlobStore { | ||
| 27 | b := make([]byte, 16) | 32 | b := make([]byte, 16) | ||
| 28 | _, e := rand.Read(b) | 33 | _, e := rand.Read(b) | ||
| @@ -35,5 +40,5 @@ func TestIntegration_Contract(t *testing.T) { | |||||
| 35 | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) })) | 40 | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) })) | ||
| 36 | }) | 41 | }) | ||
| 37 | return s | 42 | return s | ||
| 38 | - | }) | 43 | + | }, opts...) |
| 39 | } | 44 | } | ||
M internal/adapters/blobtest/suite.go +26 -4
| @@ -18,9 +18,31 @@ import ( | |||
| 18 | 18 | "github.com/stretchr/testify/require" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | + | // Option adapts Run to a provider quirk. | |
| 22 | + | type Option func(*options) | |
| 23 | + | ||
| 24 | + | type options struct{ exactKeyUploadList bool } | |
| 25 | + | ||
| 26 | + | // ExactKeyUploadList is for providers (MinIO) that list unfinished multipart | |
| 27 | + | // uploads only when the prefix is a whole object key: the suite then lists by | |
| 28 | + | // key instead of by directory prefix. | |
| 29 | + | func ExactKeyUploadList() Option { return func(o *options) { o.exactKeyUploadList = true } } | |
| 30 | + | ||
| 21 | 31 | // Run checks the blob contract against a fresh store for each subtest. | |
| 22 | - | func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |
| 32 | + | func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore, opts ...Option) { | |
| 23 | 33 | ctx := context.Background() | |
| 34 | + | var o options | |
| 35 | + | for _, opt := range opts { | |
| 36 | + | opt(&o) | |
| 37 | + | } | |
| 38 | + | // uploadPrefix is the ListMultipart prefix for key: dir, or key itself | |
| 39 | + | // on a provider that lists by whole key only. | |
| 40 | + | uploadPrefix := func(dir, key string) string { | |
| 41 | + | if o.exactKeyUploadList { | |
| 42 | + | return key | |
| 43 | + | } | |
| 44 | + | return dir | |
| 45 | + | } | |
| 24 | 46 | t.Run("roundtrip", func(t *testing.T) { | |
| 25 | 47 | s := newStore(t) | |
| 26 | 48 | require.NoError(t, s.Check(ctx)) | |
| @@ -136,7 +158,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||
| 136 | 158 | require.NoError(t, e) | |
| 137 | 159 | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | |
| 138 | 160 | var uploads []ports.MultipartInfo | |
| 139 | - | require.NoError(t, s.ListMultipart(ctx, "ci/", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |
| 161 | + | require.NoError(t, s.ListMultipart(ctx, uploadPrefix("ci/", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |
| 140 | 162 | require.Len(t, uploads, 1) | |
| 141 | 163 | require.Equal(t, u.ID(), uploads[0].ID) | |
| 142 | 164 | require.Equal(t, "ci/abort", uploads[0].Key) | |
| @@ -149,7 +171,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||
| 149 | 171 | _, e = s.Stat(ctx, "ci/abort") | |
| 150 | 172 | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 151 | 173 | uploads = nil | |
| 152 | - | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |
| 174 | + | require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |
| 153 | 175 | require.Empty(t, uploads) | |
| 154 | 176 | } | |
| 155 | 177 | }) | |
| @@ -199,7 +221,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||
| 199 | 221 | _, e = s.Stat(ctx, "ci/badlarge") | |
| 200 | 222 | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 201 | 223 | } | |
| 202 | - | require.NoError(t, s.ListMultipart(ctx, "", func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil })) | |
| 224 | + | require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/badlarge"), func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil })) | |
| 203 | 225 | }) | |
| 204 | 226 | ||
| 205 | 227 | t.Run("cancel_put", func(t *testing.T) { | |
| @@ -18,9 +18,31 @@ import ( | |||||
| 18 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | + | // Option adapts Run to a provider quirk. | |||
| 22 | + | type Option func(*options) | |||
| 23 | + | ||||
| 24 | + | type options struct{ exactKeyUploadList bool } | |||
| 25 | + | ||||
| 26 | + | // ExactKeyUploadList is for providers (MinIO) that list unfinished multipart | |||
| 27 | + | // uploads only when the prefix is a whole object key: the suite then lists by | |||
| 28 | + | // key instead of by directory prefix. | |||
| 29 | + | func ExactKeyUploadList() Option { return func(o *options) { o.exactKeyUploadList = true } } | |||
| 30 | + | ||||
| 21 | // Run checks the blob contract against a fresh store for each subtest. | 31 | // Run checks the blob contract against a fresh store for each subtest. | ||
| 22 | - | func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | 32 | + | func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore, opts ...Option) { |
| 23 | ctx := context.Background() | 33 | ctx := context.Background() | ||
| 34 | + | var o options | |||
| 35 | + | for _, opt := range opts { | |||
| 36 | + | opt(&o) | |||
| 37 | + | } | |||
| 38 | + | // uploadPrefix is the ListMultipart prefix for key: dir, or key itself | |||
| 39 | + | // on a provider that lists by whole key only. | |||
| 40 | + | uploadPrefix := func(dir, key string) string { | |||
| 41 | + | if o.exactKeyUploadList { | |||
| 42 | + | return key | |||
| 43 | + | } | |||
| 44 | + | return dir | |||
| 45 | + | } | |||
| 24 | t.Run("roundtrip", func(t *testing.T) { | 46 | t.Run("roundtrip", func(t *testing.T) { | ||
| 25 | s := newStore(t) | 47 | s := newStore(t) | ||
| 26 | require.NoError(t, s.Check(ctx)) | 48 | require.NoError(t, s.Check(ctx)) | ||
| @@ -136,7 +158,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||||
| 136 | require.NoError(t, e) | 158 | require.NoError(t, e) | ||
| 137 | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | 159 | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | ||
| 138 | var uploads []ports.MultipartInfo | 160 | var uploads []ports.MultipartInfo | ||
| 139 | - | require.NoError(t, s.ListMultipart(ctx, "ci/", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | 161 | + | require.NoError(t, s.ListMultipart(ctx, uploadPrefix("ci/", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) |
| 140 | require.Len(t, uploads, 1) | 162 | require.Len(t, uploads, 1) | ||
| 141 | require.Equal(t, u.ID(), uploads[0].ID) | 163 | require.Equal(t, u.ID(), uploads[0].ID) | ||
| 142 | require.Equal(t, "ci/abort", uploads[0].Key) | 164 | require.Equal(t, "ci/abort", uploads[0].Key) | ||
| @@ -149,7 +171,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||||
| 149 | _, e = s.Stat(ctx, "ci/abort") | 171 | _, e = s.Stat(ctx, "ci/abort") | ||
| 150 | require.ErrorIs(t, e, domain.ErrNotFound) | 172 | require.ErrorIs(t, e, domain.ErrNotFound) | ||
| 151 | uploads = nil | 173 | uploads = nil | ||
| 152 | - | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | 174 | + | require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) |
| 153 | require.Empty(t, uploads) | 175 | require.Empty(t, uploads) | ||
| 154 | } | 176 | } | ||
| 155 | }) | 177 | }) | ||
| @@ -199,7 +221,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||||
| 199 | _, e = s.Stat(ctx, "ci/badlarge") | 221 | _, e = s.Stat(ctx, "ci/badlarge") | ||
| 200 | require.ErrorIs(t, e, domain.ErrNotFound) | 222 | require.ErrorIs(t, e, domain.ErrNotFound) | ||
| 201 | } | 223 | } | ||
| 202 | - | require.NoError(t, s.ListMultipart(ctx, "", func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil })) | 224 | + | require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/badlarge"), func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil })) |
| 203 | }) | 225 | }) | ||
| 204 | 226 | ||||
| 205 | t.Run("cancel_put", func(t *testing.T) { | 227 | t.Run("cancel_put", func(t *testing.T) { | ||