diff --git a/models/actions/runner.go b/models/actions/runner.go index ba2b38ee7e9..593ebd5301a 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -251,21 +251,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond { } func (opts FindRunnerOptions) ToOrders() string { + // A unique tiebreaker (id) is appended so that runners sharing the same + // last_online or name keep a deterministic order across paginated queries, + // otherwise the same runner may appear on more than one page. switch opts.Sort { case "online": - return "last_online DESC" + return "last_online DESC, id ASC" case "offline": - return "last_online ASC" + return "last_online ASC, id ASC" case "alphabetically": - return "name ASC" + return "name ASC, id ASC" case "reversealphabetically": - return "name DESC" + return "name DESC, id ASC" case "newest": return "id DESC" case "oldest": return "id ASC" } - return "last_online DESC" + return "last_online DESC, id ASC" } // GetRunnerByUUID returns a runner via uuid diff --git a/models/actions/runner_test.go b/models/actions/runner_test.go new file mode 100644 index 00000000000..15661802fb7 --- /dev/null +++ b/models/actions/runner_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "fmt" + "testing" + + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) { + // Sorts on a non-unique column must end with the unique id tiebreaker so + // pagination is deterministic; without it, runners sharing the same + // last_online or name can appear on more than one page. Sorts already on + // the unique id need no tiebreaker. + expected := map[string]string{ + "": "last_online DESC, id ASC", + "online": "last_online DESC, id ASC", + "offline": "last_online ASC, id ASC", + "alphabetically": "name ASC, id ASC", + "reversealphabetically": "name DESC, id ASC", + "newest": "id DESC", + "oldest": "id ASC", + } + for sort, want := range expected { + assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort) + } +} + +func TestFindRunners_PaginationNoDuplicates(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + // Create several runners that all share the same last_online value so the + // primary sort key (last_online) is tied for all of them. + const ownerID = 1000 + const count = 6 + for i := range count { + runner := &ActionRunner{ + Name: "paginated-runner", + UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i), + TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i), + OwnerID: ownerID, + RepoID: 0, + LastOnline: 42, + } + require.NoError(t, db.Insert(ctx, runner)) + } + + // Page through the runners and ensure every id is returned exactly once. + seen := make(map[int64]int) + const pageSize = 2 + for page := 1; ; page++ { + runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: pageSize}, + OwnerID: ownerID, + }) + require.NoError(t, err) + if len(runners) == 0 { + break + } + for _, r := range runners { + seen[r.ID]++ + } + } + + assert.Len(t, seen, count, "each runner should be returned exactly once across all pages") + for id, n := range seen { + assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n) + } +}