detached 66c8439

perf(releases): index the latest release per repository

commit
66c843994fbbc2c682bac0cf733ef24b93b7cd49
author
brice · · 2026-10-22 08:17 UTC
parent
54492fc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +286 -8
M internal/adapters/kvstore/issues_test.go +1 -1
@@ -364,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
364364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
365365 _, err := kvstore.Open(ctx, dir)
366366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
367- assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))
367+ assert.Equal(t, uint32(7), uint32(kvstore.SchemaVersion))
368368 }
369369
370370 // A reopen racing the delete of the repository either lands before the delete
@@ -364,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
365 _, err := kvstore.Open(ctx, dir)365 _, err := kvstore.Open(ctx, dir)
366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
367- assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))367+ assert.Equal(t, uint32(7), uint32(kvstore.SchemaVersion))
368 }368 }
369 369
370 // A reopen racing the delete of the repository either lands before the delete370 // A reopen racing the delete of the repository either lands before the delete
M internal/adapters/kvstore/releases.go +18 -2
@@ -36,7 +36,18 @@ func relNamePrefix(repo domain.RepoName, id int64) string {
3636 func relNameKey(a domain.ReleaseAsset) []byte {
3737 return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)
3838 }
39-func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
39+func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) }
40+func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
41+
42+// LatestReleaseID reads the latest-release index without scanning release records.
43+func (c *Releases) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
44+ item, ok := c.db.Get(ctx, relLatestKey(repo))
45+ if !ok {
46+ return 0, false, nil
47+ }
48+ id, err := strconv.ParseInt(string(item.Value), 10, 64)
49+ return id, err == nil, err
50+}
4051 func encodeRelease(r domain.Release) []byte {
4152 return mustMarshal(&storagepb.Release{
4253 Id: r.ID,
@@ -285,6 +296,11 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
285296 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
286297 }
287298 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
299+ if w.LatestID > 0 {
300+ b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))
301+ } else {
302+ b.Delete(relLatestKey(w.Repo))
303+ }
288304 snap := c.db.Snapshot(ctx)
289305 for _, r := range w.DeleteReleases {
290306 if r.Repo != w.Repo || r.Version == 0 {
@@ -388,7 +404,7 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
388404
389405 // releaseKeys collects all repository-owned release keys, including global indexes.
390406 func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
391- keys := [][]byte{relUseKey(repo)}
407+ keys := [][]byte{relUseKey(repo), relLatestKey(repo)}
392408 for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {
393409 for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {
394410 keys = append(keys, slices.Clone(key))
@@ -36,7 +36,18 @@ func relNamePrefix(repo domain.RepoName, id int64) string {
36 func relNameKey(a domain.ReleaseAsset) []byte {36 func relNameKey(a domain.ReleaseAsset) []byte {
37 return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)37 return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)
38 }38 }
39-func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }39+func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) }
40+func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
41+
42+// LatestReleaseID reads the latest-release index without scanning release records.
43+func (c *Releases) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
44+ item, ok := c.db.Get(ctx, relLatestKey(repo))
45+ if !ok {
46+ return 0, false, nil
47+ }
48+ id, err := strconv.ParseInt(string(item.Value), 10, 64)
49+ return id, err == nil, err
50+}
40 func encodeRelease(r domain.Release) []byte {51 func encodeRelease(r domain.Release) []byte {
41 return mustMarshal(&storagepb.Release{52 return mustMarshal(&storagepb.Release{
42 Id: r.ID,53 Id: r.ID,
@@ -285,6 +296,11 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
285 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)296 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
286 }297 }
287 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))298 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
299+ if w.LatestID > 0 {
300+ b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))
301+ } else {
302+ b.Delete(relLatestKey(w.Repo))
303+ }
288 snap := c.db.Snapshot(ctx)304 snap := c.db.Snapshot(ctx)
289 for _, r := range w.DeleteReleases {305 for _, r := range w.DeleteReleases {
290 if r.Repo != w.Repo || r.Version == 0 {306 if r.Repo != w.Repo || r.Version == 0 {
@@ -388,7 +404,7 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
388 404
389 // releaseKeys collects all repository-owned release keys, including global indexes.405 // releaseKeys collects all repository-owned release keys, including global indexes.
390 func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {406 func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
391- keys := [][]byte{relUseKey(repo)}407+ keys := [][]byte{relUseKey(repo), relLatestKey(repo)}
392 for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {408 for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {
393 for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {409 for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {
394 keys = append(keys, slices.Clone(key))410 keys = append(keys, slices.Clone(key))
A internal/adapters/kvstore/releases_index.go +47 -0
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ "context"
6+ "fmt"
7+ "strconv"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+const releaseLatestIndexMarker = "meta/release-latest-index"
14+
15+// buildReleaseLatestIndex runs before the store is exposed to writers. A crash
16+// before the marker is written safely repeats the pass on the next open.
17+// Selection remains owned by domain.LatestRelease.
18+func (s *Store) buildReleaseLatestIndex(ctx context.Context) error {
19+ if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok {
20+ return nil
21+ }
22+ releases := make(map[domain.RepoName][]domain.Release)
23+ for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) {
24+ rel, err := decodeRelease(item)
25+ if err != nil {
26+ return fmt.Errorf("build latest release index: %w", err)
27+ }
28+ releases[rel.Repo] = append(releases[rel.Repo], rel)
29+ }
30+ for repo, all := range releases {
31+ var b kv.Batch
32+ if latest, ok := domain.LatestRelease(all); ok {
33+ b.Put(relLatestKey(repo), []byte(strconv.FormatInt(latest.ID, 10)))
34+ } else {
35+ b.Delete(relLatestKey(repo))
36+ }
37+ if _, err := s.db.Apply(ctx, &b); err != nil {
38+ return fmt.Errorf("build latest release index: %w", err)
39+ }
40+ }
41+ var b kv.Batch
42+ b.Put([]byte(releaseLatestIndexMarker), []byte{1})
43+ if _, err := s.db.Apply(ctx, &b); err != nil {
44+ return fmt.Errorf("mark latest release index: %w", err)
45+ }
46+ return nil
47+}
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ "context"
6+ "fmt"
7+ "strconv"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+const releaseLatestIndexMarker = "meta/release-latest-index"
14+
15+// buildReleaseLatestIndex runs before the store is exposed to writers. A crash
16+// before the marker is written safely repeats the pass on the next open.
17+// Selection remains owned by domain.LatestRelease.
18+func (s *Store) buildReleaseLatestIndex(ctx context.Context) error {
19+ if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok {
20+ return nil
21+ }
22+ releases := make(map[domain.RepoName][]domain.Release)
23+ for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) {
24+ rel, err := decodeRelease(item)
25+ if err != nil {
26+ return fmt.Errorf("build latest release index: %w", err)
27+ }
28+ releases[rel.Repo] = append(releases[rel.Repo], rel)
29+ }
30+ for repo, all := range releases {
31+ var b kv.Batch
32+ if latest, ok := domain.LatestRelease(all); ok {
33+ b.Put(relLatestKey(repo), []byte(strconv.FormatInt(latest.ID, 10)))
34+ } else {
35+ b.Delete(relLatestKey(repo))
36+ }
37+ if _, err := s.db.Apply(ctx, &b); err != nil {
38+ return fmt.Errorf("build latest release index: %w", err)
39+ }
40+ }
41+ var b kv.Batch
42+ b.Put([]byte(releaseLatestIndexMarker), []byte{1})
43+ if _, err := s.db.Apply(ctx, &b); err != nil {
44+ return fmt.Errorf("mark latest release index: %w", err)
45+ }
46+ return nil
47+}
A internal/adapters/kvstore/releases_index_test.go +54 -0
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ kv "bnema.dev/ermite-kv"
6+ "bnema.dev/ermite/internal/domain"
7+ "bnema.dev/ermite/internal/ports"
8+ "context"
9+ "github.com/stretchr/testify/require"
10+ "testing"
11+)
12+
13+func TestReleaseLatestIndexBackfill(t *testing.T) {
14+ ctx := t.Context()
15+ dir := t.TempDir()
16+ s, err := Open(ctx, dir)
17+ require.NoError(t, err)
18+ repo, err := domain.ParseRepoName("backfill")
19+ require.NoError(t, err)
20+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
21+ rel := domain.Release{ID: 1, Repo: repo, Tag: "v1"}
22+ require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{rel}, LatestID: 1}))
23+ id, ok, err := s.Releases().LatestReleaseID(ctx, repo)
24+ require.NoError(t, err)
25+ require.True(t, ok)
26+ require.EqualValues(t, 1, id)
27+ _, version, err := s.Releases().ReleaseUsage(ctx, repo)
28+ require.NoError(t, err)
29+ require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version}))
30+ _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
31+ require.NoError(t, err)
32+ require.False(t, ok)
33+ // Simulate pre-index data, then exercise the actual startup hook.
34+ var b kv.Batch
35+ b.Delete([]byte(releaseLatestIndexMarker))
36+ _, err = s.db.Apply(ctx, &b)
37+ require.NoError(t, err)
38+ require.NoError(t, s.Close(context.Background()))
39+ s, err = Open(ctx, dir)
40+ require.NoError(t, err)
41+ defer func() { require.NoError(t, s.Close(context.Background())) }()
42+ id, ok, err = s.Releases().LatestReleaseID(ctx, repo)
43+ require.NoError(t, err)
44+ require.True(t, ok)
45+ require.EqualValues(t, 1, id)
46+ require.NoError(t, s.Delete(ctx, repo))
47+ _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
48+ require.NoError(t, err)
49+ require.False(t, ok)
50+ require.NoError(t, s.buildReleaseLatestIndex(ctx))
51+ _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
52+ require.NoError(t, err)
53+ require.False(t, ok)
54+}
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ kv "bnema.dev/ermite-kv"
6+ "bnema.dev/ermite/internal/domain"
7+ "bnema.dev/ermite/internal/ports"
8+ "context"
9+ "github.com/stretchr/testify/require"
10+ "testing"
11+)
12+
13+func TestReleaseLatestIndexBackfill(t *testing.T) {
14+ ctx := t.Context()
15+ dir := t.TempDir()
16+ s, err := Open(ctx, dir)
17+ require.NoError(t, err)
18+ repo, err := domain.ParseRepoName("backfill")
19+ require.NoError(t, err)
20+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
21+ rel := domain.Release{ID: 1, Repo: repo, Tag: "v1"}
22+ require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{rel}, LatestID: 1}))
23+ id, ok, err := s.Releases().LatestReleaseID(ctx, repo)
24+ require.NoError(t, err)
25+ require.True(t, ok)
26+ require.EqualValues(t, 1, id)
27+ _, version, err := s.Releases().ReleaseUsage(ctx, repo)
28+ require.NoError(t, err)
29+ require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version}))
30+ _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
31+ require.NoError(t, err)
32+ require.False(t, ok)
33+ // Simulate pre-index data, then exercise the actual startup hook.
34+ var b kv.Batch
35+ b.Delete([]byte(releaseLatestIndexMarker))
36+ _, err = s.db.Apply(ctx, &b)
37+ require.NoError(t, err)
38+ require.NoError(t, s.Close(context.Background()))
39+ s, err = Open(ctx, dir)
40+ require.NoError(t, err)
41+ defer func() { require.NoError(t, s.Close(context.Background())) }()
42+ id, ok, err = s.Releases().LatestReleaseID(ctx, repo)
43+ require.NoError(t, err)
44+ require.True(t, ok)
45+ require.EqualValues(t, 1, id)
46+ require.NoError(t, s.Delete(ctx, repo))
47+ _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
48+ require.NoError(t, err)
49+ require.False(t, ok)
50+ require.NoError(t, s.buildReleaseLatestIndex(ctx))
51+ _, ok, err = s.Releases().LatestReleaseID(ctx, repo)
52+ require.NoError(t, err)
53+ require.False(t, ok)
54+}
M internal/adapters/kvstore/store.go +6 -1
@@ -43,7 +43,8 @@ import (
4343 // holding all eleven previous scopes, preserving their owner rights. Version 6
4444 // adds releases and release:write; older builds cannot clean up their metadata
4545 // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).
46-const SchemaVersion = 6
46+// Version 7 adds the latest-release index; older writers would leave it stale.
47+const SchemaVersion = 7
4748
4849 const schemaKey = "meta/schema"
4950
@@ -76,6 +77,10 @@ func Open(ctx context.Context, dir string) (*Store, error) {
7677 _ = db.Close(context.WithoutCancel(ctx))
7778 return nil, err
7879 }
80+ if err := s.buildReleaseLatestIndex(ctx); err != nil {
81+ _ = db.Close(context.WithoutCancel(ctx))
82+ return nil, err
83+ }
7984 return s, nil
8085 }
8186
@@ -43,7 +43,8 @@ import (
43 // holding all eleven previous scopes, preserving their owner rights. Version 643 // holding all eleven previous scopes, preserving their owner rights. Version 6
44 // adds releases and release:write; older builds cannot clean up their metadata44 // adds releases and release:write; older builds cannot clean up their metadata
45 // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).45 // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).
46-const SchemaVersion = 646+// Version 7 adds the latest-release index; older writers would leave it stale.
47+const SchemaVersion = 7
47 48
48 const schemaKey = "meta/schema"49 const schemaKey = "meta/schema"
49 50
@@ -76,6 +77,10 @@ func Open(ctx context.Context, dir string) (*Store, error) {
76 _ = db.Close(context.WithoutCancel(ctx))77 _ = db.Close(context.WithoutCancel(ctx))
77 return nil, err78 return nil, err
78 }79 }
80+ if err := s.buildReleaseLatestIndex(ctx); err != nil {
81+ _ = db.Close(context.WithoutCancel(ctx))
82+ return nil, err
83+ }
79 return s, nil84 return s, nil
80 }85 }
81 86
M internal/ports/mocks/ReleaseStore.go +72 -0
@@ -455,6 +455,78 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C
455455 return _c
456456 }
457457
458+// LatestReleaseID provides a mock function for the type ReleaseStore
459+func (_mock *ReleaseStore) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
460+ ret := _mock.Called(ctx, repo)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for LatestReleaseID")
464+ }
465+
466+ var r0 int64
467+ var r1 bool
468+ var r2 error
469+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, bool, error)); ok {
470+ return returnFunc(ctx, repo)
471+ }
472+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok {
473+ r0 = returnFunc(ctx, repo)
474+ } else {
475+ r0 = ret.Get(0).(int64)
476+ }
477+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
478+ r1 = returnFunc(ctx, repo)
479+ } else {
480+ r1 = ret.Get(1).(bool)
481+ }
482+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
483+ r2 = returnFunc(ctx, repo)
484+ } else {
485+ r2 = ret.Error(2)
486+ }
487+ return r0, r1, r2
488+}
489+
490+// ReleaseStore_LatestReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestReleaseID'
491+type ReleaseStore_LatestReleaseID_Call struct {
492+ *mock.Call
493+}
494+
495+// LatestReleaseID is a helper method to define mock.On call
496+// - ctx context.Context
497+// - repo domain.RepoName
498+func (_e *ReleaseStore_Expecter) LatestReleaseID(ctx any, repo any) *ReleaseStore_LatestReleaseID_Call {
499+ return &ReleaseStore_LatestReleaseID_Call{Call: _e.mock.On("LatestReleaseID", ctx, repo)}
500+}
501+
502+func (_c *ReleaseStore_LatestReleaseID_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_LatestReleaseID_Call {
503+ _c.Call.Run(func(args mock.Arguments) {
504+ var arg0 context.Context
505+ if args[0] != nil {
506+ arg0 = args[0].(context.Context)
507+ }
508+ var arg1 domain.RepoName
509+ if args[1] != nil {
510+ arg1 = args[1].(domain.RepoName)
511+ }
512+ run(
513+ arg0,
514+ arg1,
515+ )
516+ })
517+ return _c
518+}
519+
520+func (_c *ReleaseStore_LatestReleaseID_Call) Return(id int64, ok bool, err error) *ReleaseStore_LatestReleaseID_Call {
521+ _c.Call.Return(id, ok, err)
522+ return _c
523+}
524+
525+func (_c *ReleaseStore_LatestReleaseID_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, bool, error)) *ReleaseStore_LatestReleaseID_Call {
526+ _c.Call.Return(run)
527+ return _c
528+}
529+
458530 // ListAssets provides a mock function for the type ReleaseStore
459531 func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
460532 ret := _mock.Called(ctx, repo, releaseID)
@@ -455,6 +455,78 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C
455 return _c455 return _c
456 }456 }
457 457
458+// LatestReleaseID provides a mock function for the type ReleaseStore
459+func (_mock *ReleaseStore) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) {
460+ ret := _mock.Called(ctx, repo)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for LatestReleaseID")
464+ }
465+
466+ var r0 int64
467+ var r1 bool
468+ var r2 error
469+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, bool, error)); ok {
470+ return returnFunc(ctx, repo)
471+ }
472+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok {
473+ r0 = returnFunc(ctx, repo)
474+ } else {
475+ r0 = ret.Get(0).(int64)
476+ }
477+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
478+ r1 = returnFunc(ctx, repo)
479+ } else {
480+ r1 = ret.Get(1).(bool)
481+ }
482+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
483+ r2 = returnFunc(ctx, repo)
484+ } else {
485+ r2 = ret.Error(2)
486+ }
487+ return r0, r1, r2
488+}
489+
490+// ReleaseStore_LatestReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestReleaseID'
491+type ReleaseStore_LatestReleaseID_Call struct {
492+ *mock.Call
493+}
494+
495+// LatestReleaseID is a helper method to define mock.On call
496+// - ctx context.Context
497+// - repo domain.RepoName
498+func (_e *ReleaseStore_Expecter) LatestReleaseID(ctx any, repo any) *ReleaseStore_LatestReleaseID_Call {
499+ return &ReleaseStore_LatestReleaseID_Call{Call: _e.mock.On("LatestReleaseID", ctx, repo)}
500+}
501+
502+func (_c *ReleaseStore_LatestReleaseID_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_LatestReleaseID_Call {
503+ _c.Call.Run(func(args mock.Arguments) {
504+ var arg0 context.Context
505+ if args[0] != nil {
506+ arg0 = args[0].(context.Context)
507+ }
508+ var arg1 domain.RepoName
509+ if args[1] != nil {
510+ arg1 = args[1].(domain.RepoName)
511+ }
512+ run(
513+ arg0,
514+ arg1,
515+ )
516+ })
517+ return _c
518+}
519+
520+func (_c *ReleaseStore_LatestReleaseID_Call) Return(id int64, ok bool, err error) *ReleaseStore_LatestReleaseID_Call {
521+ _c.Call.Return(id, ok, err)
522+ return _c
523+}
524+
525+func (_c *ReleaseStore_LatestReleaseID_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, bool, error)) *ReleaseStore_LatestReleaseID_Call {
526+ _c.Call.Return(run)
527+ return _c
528+}
529+
458 // ListAssets provides a mock function for the type ReleaseStore530 // ListAssets provides a mock function for the type ReleaseStore
459 func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {531 func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) {
460 ret := _mock.Called(ctx, repo, releaseID)532 ret := _mock.Called(ctx, repo, releaseID)
M internal/ports/release.go +2 -0
@@ -13,6 +13,7 @@ import (
1313 // Used includes both uploading and uploaded assets.
1414 type ReleaseWrite struct {
1515 Repo domain.RepoName
16+ LatestID int64 // zero means no eligible release
1617 Used int64
1718 UsageVersion uint64
1819 PutReleases, DeleteReleases []domain.Release
@@ -21,6 +22,7 @@ type ReleaseWrite struct {
2122
2223 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.
2324 type ReleaseStore interface {
25+ LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error)
2426 NextReleaseID(ctx context.Context) (int64, error)
2527 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)
2628 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)
@@ -13,6 +13,7 @@ import (
13 // Used includes both uploading and uploaded assets.13 // Used includes both uploading and uploaded assets.
14 type ReleaseWrite struct {14 type ReleaseWrite struct {
15 Repo domain.RepoName15 Repo domain.RepoName
16+ LatestID int64 // zero means no eligible release
16 Used int6417 Used int64
17 UsageVersion uint6418 UsageVersion uint64
18 PutReleases, DeleteReleases []domain.Release19 PutReleases, DeleteReleases []domain.Release
@@ -21,6 +22,7 @@ type ReleaseWrite struct {
21 22
22 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.23 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.
23 type ReleaseStore interface {24 type ReleaseStore interface {
25+ LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error)
24 NextReleaseID(ctx context.Context) (int64, error)26 NextReleaseID(ctx context.Context) (int64, error)
25 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)27 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)
26 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)28 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)
M internal/usecase/releases.go +26 -4
@@ -92,6 +92,27 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn
9292 if err = fn(&w); err != nil {
9393 return err
9494 }
95+ all, err := r.store.ListReleases(ctx, repo)
96+ if err != nil {
97+ return err
98+ }
99+ byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))
100+ for _, rel := range all {
101+ byID[rel.ID] = rel
102+ }
103+ for _, rel := range w.DeleteReleases {
104+ delete(byID, rel.ID)
105+ }
106+ for _, rel := range w.PutReleases {
107+ byID[rel.ID] = rel
108+ }
109+ all = all[:0]
110+ for _, rel := range byID {
111+ all = append(all, rel)
112+ }
113+ if latest, ok := domain.LatestRelease(all); ok {
114+ w.LatestID = latest.ID
115+ }
95116 err = r.store.ApplyReleases(ctx, w)
96117 if errors.Is(err, domain.ErrConflict) {
97118 continue
@@ -245,13 +266,14 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma
245266 case ref.Tag != "":
246267 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
247268 default:
248- var all []domain.Release
249- all, err = r.store.ListReleases(ctx, name)
269+ var id int64
270+ var ok bool
271+ id, ok, err = r.store.LatestReleaseID(ctx, name)
250272 if err == nil {
251- var ok bool
252- rel, ok = domain.LatestRelease(all)
253273 if !ok {
254274 err = domain.ErrNotFound
275+ } else {
276+ rel, err = r.store.GetRelease(ctx, name, id)
255277 }
256278 }
257279 }
@@ -92,6 +92,27 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn
92 if err = fn(&w); err != nil {92 if err = fn(&w); err != nil {
93 return err93 return err
94 }94 }
95+ all, err := r.store.ListReleases(ctx, repo)
96+ if err != nil {
97+ return err
98+ }
99+ byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))
100+ for _, rel := range all {
101+ byID[rel.ID] = rel
102+ }
103+ for _, rel := range w.DeleteReleases {
104+ delete(byID, rel.ID)
105+ }
106+ for _, rel := range w.PutReleases {
107+ byID[rel.ID] = rel
108+ }
109+ all = all[:0]
110+ for _, rel := range byID {
111+ all = append(all, rel)
112+ }
113+ if latest, ok := domain.LatestRelease(all); ok {
114+ w.LatestID = latest.ID
115+ }
95 err = r.store.ApplyReleases(ctx, w)116 err = r.store.ApplyReleases(ctx, w)
96 if errors.Is(err, domain.ErrConflict) {117 if errors.Is(err, domain.ErrConflict) {
97 continue118 continue
@@ -245,13 +266,14 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma
245 case ref.Tag != "":266 case ref.Tag != "":
246 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)267 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
247 default:268 default:
248- var all []domain.Release269+ var id int64
249- all, err = r.store.ListReleases(ctx, name)270+ var ok bool
271+ id, ok, err = r.store.LatestReleaseID(ctx, name)
250 if err == nil {272 if err == nil {
251- var ok bool
252- rel, ok = domain.LatestRelease(all)
253 if !ok {273 if !ok {
254 err = domain.ErrNotFound274 err = domain.ErrNotFound
275+ } else {
276+ rel, err = r.store.GetRelease(ctx, name, id)
255 }277 }
256 }278 }
257 }279 }
M internal/usecase/releases_integration_test.go +60 -0
@@ -263,6 +263,7 @@ func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
263263 reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264264 store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265265 store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266+ store.EXPECT().ListReleases(ctx, repo).Return(nil, nil).Twice()
266267 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267268 store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268269 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
@@ -334,3 +335,62 @@ func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) {
334335 require.NoError(t, err)
335336 require.Equal(t, oidA, got.TargetCommitish)
336337 }
338+
339+func TestReleaseLatestIndexTransitions(t *testing.T) {
340+ r := newReleaseRig(t, ports.ReleaseLimits{})
341+ latest := func(id int64) {
342+ t.Helper()
343+ got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
344+ if id == 0 {
345+ require.ErrorIs(t, err, domain.ErrNotFound)
346+ return
347+ }
348+ require.NoError(t, err)
349+ require.Equal(t, id, got.ID)
350+ }
351+ update := func(id int64, p ports.UpdateReleaseParams) {
352+ t.Helper()
353+ _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, id, p)
354+ require.NoError(t, err)
355+ }
356+ yes, no := true, false
357+ draft := r.create(t, "draft", true)
358+ latest(0)
359+ first := r.create(t, "v1", false)
360+ latest(first.ID)
361+ update(draft.ID, ports.UpdateReleaseParams{Draft: &no})
362+ latest(first.ID) // equal creation times: higher ID wins
363+ second := r.create(t, "v2", false)
364+ latest(second.ID)
365+ update(second.ID, ports.UpdateReleaseParams{Prerelease: &yes})
366+ latest(first.ID)
367+ update(second.ID, ports.UpdateReleaseParams{Prerelease: &no})
368+ latest(second.ID)
369+ update(first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
370+ latest(first.ID)
371+ tag := "renamed"
372+ r.resolve(t, tag, nil)
373+ update(first.ID, ports.UpdateReleaseParams{Tag: &tag})
374+ latest(first.ID)
375+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
376+ latest(second.ID)
377+ update(second.ID, ports.UpdateReleaseParams{Draft: &yes})
378+ latest(draft.ID)
379+ update(draft.ID, ports.UpdateReleaseParams{Draft: &yes})
380+ latest(0)
381+}
382+
383+// No ListReleases expectation: latest resolution must use only point reads.
384+func TestReleaseLatestUsesIndex(t *testing.T) {
385+ ctx := t.Context()
386+ repo := name(t, "indexed")
387+ store := mocks.NewReleaseStore(t)
388+ meta := mocks.NewRepoMetaStore(t)
389+ meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
390+ store.EXPECT().LatestReleaseID(ctx, repo).Return(int64(42), true, nil).Once()
391+ store.EXPECT().GetRelease(ctx, repo, int64(42)).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
392+ svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})
393+ got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})
394+ require.NoError(t, err)
395+ require.EqualValues(t, 42, got.ID)
396+}
@@ -263,6 +263,7 @@ func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
263 reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)263 reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264 store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)264 store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265 store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)265 store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266+ store.EXPECT().ListReleases(ctx, repo).Return(nil, nil).Twice()
266 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()267 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267 store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()268 store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()269 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
@@ -334,3 +335,62 @@ func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) {
334 require.NoError(t, err)335 require.NoError(t, err)
335 require.Equal(t, oidA, got.TargetCommitish)336 require.Equal(t, oidA, got.TargetCommitish)
336 }337 }
338+
339+func TestReleaseLatestIndexTransitions(t *testing.T) {
340+ r := newReleaseRig(t, ports.ReleaseLimits{})
341+ latest := func(id int64) {
342+ t.Helper()
343+ got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
344+ if id == 0 {
345+ require.ErrorIs(t, err, domain.ErrNotFound)
346+ return
347+ }
348+ require.NoError(t, err)
349+ require.Equal(t, id, got.ID)
350+ }
351+ update := func(id int64, p ports.UpdateReleaseParams) {
352+ t.Helper()
353+ _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, id, p)
354+ require.NoError(t, err)
355+ }
356+ yes, no := true, false
357+ draft := r.create(t, "draft", true)
358+ latest(0)
359+ first := r.create(t, "v1", false)
360+ latest(first.ID)
361+ update(draft.ID, ports.UpdateReleaseParams{Draft: &no})
362+ latest(first.ID) // equal creation times: higher ID wins
363+ second := r.create(t, "v2", false)
364+ latest(second.ID)
365+ update(second.ID, ports.UpdateReleaseParams{Prerelease: &yes})
366+ latest(first.ID)
367+ update(second.ID, ports.UpdateReleaseParams{Prerelease: &no})
368+ latest(second.ID)
369+ update(first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
370+ latest(first.ID)
371+ tag := "renamed"
372+ r.resolve(t, tag, nil)
373+ update(first.ID, ports.UpdateReleaseParams{Tag: &tag})
374+ latest(first.ID)
375+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
376+ latest(second.ID)
377+ update(second.ID, ports.UpdateReleaseParams{Draft: &yes})
378+ latest(draft.ID)
379+ update(draft.ID, ports.UpdateReleaseParams{Draft: &yes})
380+ latest(0)
381+}
382+
383+// No ListReleases expectation: latest resolution must use only point reads.
384+func TestReleaseLatestUsesIndex(t *testing.T) {
385+ ctx := t.Context()
386+ repo := name(t, "indexed")
387+ store := mocks.NewReleaseStore(t)
388+ meta := mocks.NewRepoMetaStore(t)
389+ meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
390+ store.EXPECT().LatestReleaseID(ctx, repo).Return(int64(42), true, nil).Once()
391+ store.EXPECT().GetRelease(ctx, repo, int64(42)).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
392+ svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})
393+ got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})
394+ require.NoError(t, err)
395+ require.EqualValues(t, 42, got.ID)
396+}

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 3

Tags 44

↑↓ moveenter openesc close