detached 6abe390

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
5151 support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts.
5252 The daily sweep removes unreferenced blobs and unfinished uploads older than
5353 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).
5457
5558 ## Backend changes
5659
@@ -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 than52 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 changes58 ## Backend changes
56 59
M internal/adapters/blobs3/integration_test.go +6 -1
@@ -23,6 +23,11 @@ func TestIntegration_Contract(t *testing.T) {
2323 if region == "" {
2424 region = "us-east-1"
2525 }
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+ }
2631 blobtest.Run(t, func(t *testing.T) ports.BlobStore {
2732 b := make([]byte, 16)
2833 _, e := rand.Read(b)
@@ -35,5 +40,5 @@ func TestIntegration_Contract(t *testing.T) {
3540 require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) }))
3641 })
3742 return s
38- })
43+ }, opts...)
3944 }
@@ -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 s42 return s
38- })43+ }, opts...)
39 }44 }
M internal/adapters/blobtest/suite.go +26 -4
@@ -18,9 +18,31 @@ import (
1818 "github.com/stretchr/testify/require"
1919 )
2020
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+
2131 // 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) {
2333 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+ }
2446 t.Run("roundtrip", func(t *testing.T) {
2547 s := newStore(t)
2648 require.NoError(t, s.Check(ctx))
@@ -136,7 +158,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) {
136158 require.NoError(t, e)
137159 require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
138160 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 }))
140162 require.Len(t, uploads, 1)
141163 require.Equal(t, u.ID(), uploads[0].ID)
142164 require.Equal(t, "ci/abort", uploads[0].Key)
@@ -149,7 +171,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) {
149171 _, e = s.Stat(ctx, "ci/abort")
150172 require.ErrorIs(t, e, domain.ErrNotFound)
151173 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 }))
153175 require.Empty(t, uploads)
154176 }
155177 })
@@ -199,7 +221,7 @@ func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) {
199221 _, e = s.Stat(ctx, "ci/badlarge")
200222 require.ErrorIs(t, e, domain.ErrNotFound)
201223 }
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 }))
203225 })
204226
205227 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.MultipartInfo160 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 = nil173 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close