perf(releases): index the latest release per repository
- commit
- 66c843994fbbc2c682bac0cf733ef24b93b7cd49
- author
- brice · · 2026-10-22 08:17 UTC
- parent
- 54492fc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 364 | 364 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | |
| 365 | 365 | _, err := kvstore.Open(ctx, dir) | |
| 366 | 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 | 370 | // 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 delete | 370 | // 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 { | |||
| 36 | 36 | func relNameKey(a domain.ReleaseAsset) []byte { | |
| 37 | 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 | 51 | func encodeRelease(r domain.Release) []byte { | |
| 41 | 52 | return mustMarshal(&storagepb.Release{ | |
| 42 | 53 | Id: r.ID, | |
| @@ -285,6 +296,11 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||
| 285 | 296 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | |
| 286 | 297 | } | |
| 287 | 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 | 304 | snap := c.db.Snapshot(ctx) | |
| 289 | 305 | for _, r := range w.DeleteReleases { | |
| 290 | 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 | 405 | // releaseKeys collects all repository-owned release keys, including global indexes. | |
| 390 | 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 | 408 | for _, prefix := range []string{"rel/", "reltag/", "relassets/"} { | |
| 393 | 409 | for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) { | |
| 394 | 410 | 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 ( | |||
| 43 | 43 | // holding all eleven previous scopes, preserving their owner rights. Version 6 | |
| 44 | 44 | // adds releases and release:write; older builds cannot clean up their metadata | |
| 45 | 45 | // 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 | |
| 47 | 48 | ||
| 48 | 49 | const schemaKey = "meta/schema" | |
| 49 | 50 | ||
| @@ -76,6 +77,10 @@ func Open(ctx context.Context, dir string) (*Store, error) { | |||
| 76 | 77 | _ = db.Close(context.WithoutCancel(ctx)) | |
| 77 | 78 | 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 | 84 | return s, nil | |
| 80 | 85 | } | |
| 81 | 86 | ||
| @@ -43,7 +43,8 @@ import ( | |||||
| 43 | // holding all eleven previous scopes, preserving their owner rights. Version 6 | 43 | // holding all eleven previous scopes, preserving their owner rights. Version 6 | ||
| 44 | // adds releases and release:write; older builds cannot clean up their metadata | 44 | // 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 = 6 | 46 | + | // 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, err | 78 | 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, nil | 84 | 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 | |||
| 455 | 455 | 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 | 530 | // ListAssets provides a mock function for the type ReleaseStore | |
| 459 | 531 | func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | |
| 460 | 532 | ret := _mock.Called(ctx, repo, releaseID) | |
| @@ -455,6 +455,78 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C | |||||
| 455 | return _c | 455 | 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 ReleaseStore | 530 | // 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 ( | |||
| 13 | 13 | // Used includes both uploading and uploaded assets. | |
| 14 | 14 | type ReleaseWrite struct { | |
| 15 | 15 | Repo domain.RepoName | |
| 16 | + | LatestID int64 // zero means no eligible release | |
| 16 | 17 | Used int64 | |
| 17 | 18 | UsageVersion uint64 | |
| 18 | 19 | PutReleases, DeleteReleases []domain.Release | |
| @@ -21,6 +22,7 @@ type ReleaseWrite struct { | |||
| 21 | 22 | ||
| 22 | 23 | // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it. | |
| 23 | 24 | type ReleaseStore interface { | |
| 25 | + | LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error) | |
| 24 | 26 | NextReleaseID(ctx context.Context) (int64, error) | |
| 25 | 27 | ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error) | |
| 26 | 28 | 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.RepoName | 15 | Repo domain.RepoName | ||
| 16 | + | LatestID int64 // zero means no eligible release | |||
| 16 | Used int64 | 17 | Used int64 | ||
| 17 | UsageVersion uint64 | 18 | UsageVersion uint64 | ||
| 18 | PutReleases, DeleteReleases []domain.Release | 19 | 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 | |||
| 92 | 92 | if err = fn(&w); err != nil { | |
| 93 | 93 | 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 | 116 | err = r.store.ApplyReleases(ctx, w) | |
| 96 | 117 | if errors.Is(err, domain.ErrConflict) { | |
| 97 | 118 | continue | |
| @@ -245,13 +266,14 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma | |||
| 245 | 266 | case ref.Tag != "": | |
| 246 | 267 | rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag) | |
| 247 | 268 | 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) | |
| 250 | 272 | if err == nil { | |
| 251 | - | var ok bool | |
| 252 | - | rel, ok = domain.LatestRelease(all) | |
| 253 | 273 | if !ok { | |
| 254 | 274 | err = domain.ErrNotFound | |
| 275 | + | } else { | |
| 276 | + | rel, err = r.store.GetRelease(ctx, name, id) | |
| 255 | 277 | } | |
| 256 | 278 | } | |
| 257 | 279 | } | |
| @@ -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 err | 93 | 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 | continue | 118 | 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.Release | 269 | + | 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.ErrNotFound | 274 | 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) { | |||
| 263 | 263 | reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound) | |
| 264 | 264 | store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound) | |
| 265 | 265 | store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil) | |
| 266 | + | store.EXPECT().ListReleases(ctx, repo).Return(nil, nil).Twice() | |
| 266 | 267 | store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once() | |
| 267 | 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 | 269 | store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once() | |
| @@ -334,3 +335,62 @@ func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) { | |||
| 334 | 335 | require.NoError(t, err) | |
| 335 | 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 | + | } | |
| @@ -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 | + | } | |||