Commit 540541e119

540541e119c3ff85bec02ce57b6a58f7f4c9037e

parent: 161f585ca2

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-02 03:55 UTC

store: break dashboard updated_at ties by id

Dashboard queries order by updated_at DESC, id DESC, so rows written in
the same millisecond come back newest first, as distinct times do.
Migration 0076 rebuilds issues_recent and merge_requests_recent as
(updated_at DESC, id DESC) so the walk still needs no sort; the plan
test now also rejects a partial sort.

Closes #320

Layout: unified · split

internal/store/dashboard.go +5 −5
@@ -60,7 +60,7 @@ const dashboardMRsQuery = `
6060 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
6161 JOIN users au ON au.id = x.author_id
6262 WHERE x.state IN ('open', 'source_gone') AND ` + involvedCond + `
63 ORDER BY x.updated_at DESC LIMIT 50`
63 ORDER BY x.updated_at DESC, x.id DESC LIMIT 50`
6464
6565// DashboardMRs returns open merge requests involving the user: on their
6666// repositories (owned, granted, org) or authored by them anywhere.
@@ -80,7 +80,7 @@ const dashboardIssuesQuery = `
8080 WHERE x.state = 'open' AND ` + involvedCond + `
8181 AND NOT EXISTS (SELECT 1 FROM issue_assignees ia
8282 WHERE ia.issue_id = x.id AND ia.user_id = ?1)
83 ORDER BY x.updated_at DESC LIMIT 50`
83 ORDER BY x.updated_at DESC, x.id DESC LIMIT 50`
8484
8585func (s *Store) DashboardIssues(userID int64) ([]DashboardItem, error) {
8686 return s.dashboardQuery(dashboardIssuesQuery, userID)
@@ -152,7 +152,7 @@ const reviewQueueQuery = `
152152 WHERE rv.mr_id = x.id AND rv.reviewer_id = ?1
153153 AND rv.head_sha = x.head_sha)
154154 AND ` + involvedCond + `
155 ORDER BY x.updated_at DESC LIMIT 8`
155 ORDER BY x.updated_at DESC, x.id DESC LIMIT 8`
156156
157157// requestedReviewsQuery is ReviewQueue's other half: MRs where the user was
158158// asked directly, regardless of involvement — the same exemption
@@ -177,7 +177,7 @@ const requestedReviewsQuery = `
177177 AND NOT EXISTS (SELECT 1 FROM mr_reviews rv
178178 WHERE rv.mr_id = x.id AND rv.reviewer_id = ?1
179179 AND rv.head_sha = x.head_sha)
180 ORDER BY x.updated_at DESC LIMIT 8`
180 ORDER BY x.updated_at DESC, x.id DESC LIMIT 8`
181181
182182// ReviewQueue returns open merge requests the user is involved in, has not
183183// authored, and has not reviewed at the current head — what the rail shows
@@ -245,7 +245,7 @@ const assignedIssuesQuery = `
245245 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
246246 JOIN users au ON au.id = x.author_id
247247 WHERE ia.user_id = ?1 AND x.state = 'open'
248 ORDER BY x.updated_at DESC LIMIT 20`
248 ORDER BY x.updated_at DESC, x.id DESC LIMIT 20`
249249
250250func (s *Store) AssignedIssues(userID int64) ([]DashboardItem, error) {
251251 return s.dashboardQuery(assignedIssuesQuery, userID)
internal/store/dashboard_test.go +74 −1
@@ -1,6 +1,79 @@
11package store
22
3import "testing"
3import (
4 "reflect"
5 "testing"
6)
7
8// Rows sharing an updated_at (timestamps are milliseconds) come back
9// newest first, the same order distinct times give, so the dashboard does
10// not reshuffle depending on whether two writes landed in one millisecond.
11func TestDashboardEqualUpdatedAtStableOrder(t *testing.T) {
12 s := open(t)
13 if err := s.MigrateUp(); err != nil {
14 t.Fatal(err)
15 }
16 uid, err := s.CreateUser("cmc", false)
17 if err != nil {
18 t.Fatal(err)
19 }
20 other, err := s.CreateUser("alice", false)
21 if err != nil {
22 t.Fatal(err)
23 }
24 repoID, err := s.CreateRepo("user", uid, "gitbay", "public")
25 if err != nil {
26 t.Fatal(err)
27 }
28 for _, title := range []string{"first", "second", "third"} {
29 n, err := s.CreateIssue(repoID, uid, title, "", "markdown")
30 if err != nil {
31 t.Fatal(err)
32 }
33 issue, err := s.IssueByNumber(repoID, n)
34 if err != nil {
35 t.Fatal(err)
36 }
37 if err := s.SetIssueAssignee(issue.ID, other, true); err != nil {
38 t.Fatal(err)
39 }
40 if _, err := s.CreateMR(repoID, other, repoID, title, "main", title, "", "abc", "md", false); err != nil {
41 t.Fatal(err)
42 }
43 }
44 for _, table := range []string{"issues", "merge_requests"} {
45 if _, err := s.DB.Exec("UPDATE " + table + " SET updated_at = '2026-10-01T12:00:00.000Z'"); err != nil {
46 t.Fatal(err)
47 }
48 }
49
50 titles := func(items []DashboardItem, err error) []string {
51 t.Helper()
52 if err != nil {
53 t.Fatal(err)
54 }
55 var out []string
56 for _, d := range items {
57 out = append(out, d.Title)
58 }
59 return out
60 }
61 want := []string{"third", "second", "first"}
62 cases := []struct {
63 name string
64 got []string
65 }{
66 {"DashboardIssues", titles(s.DashboardIssues(uid))},
67 {"DashboardMRs", titles(s.DashboardMRs(uid))},
68 {"ReviewQueue", titles(s.ReviewQueue(uid))},
69 {"AssignedIssues", titles(s.AssignedIssues(other))},
70 }
71 for _, tc := range cases {
72 if !reflect.DeepEqual(tc.got, want) {
73 t.Errorf("%s = %v, want %v", tc.name, tc.got, want)
74 }
75 }
76}
477
578// An issue assigned to the user is not repeated under DashboardIssues:
679// AssignedIssues already covers it, and a repository the user can
internal/store/dashboardplan_test.go +1 −1
@@ -60,7 +60,7 @@ func TestDashboardQueriesUseIndexes(t *testing.T) {
6060 if !strings.Contains(tc.plan, tc.want) {
6161 t.Errorf("%s does not use %s:\n%s", tc.name, tc.want, tc.plan)
6262 }
63 if tc.ordered && strings.Contains(tc.plan, "USE TEMP B-TREE FOR ORDER BY") {
63 if tc.ordered && strings.Contains(tc.plan, "USE TEMP B-TREE") {
6464 t.Errorf("%s sorts instead of walking an index:\n%s", tc.name, tc.plan)
6565 }
6666 }
internal/store/migrations/0078_recent_id_order.down.sql added +4
@@ -0,0 +1,4 @@
1DROP INDEX issues_recent;
2DROP INDEX merge_requests_recent;
3CREATE INDEX issues_recent ON issues(updated_at DESC);
4CREATE INDEX merge_requests_recent ON merge_requests(updated_at DESC);
internal/store/migrations/0078_recent_id_order.up.sql added +7
@@ -0,0 +1,7 @@
1-- The dashboard breaks updated_at ties by id, newest first. The 0035
2-- indexes carry rowid ascending after updated_at, so that order would
3-- sort the table instead of walking the index.
4DROP INDEX issues_recent;
5DROP INDEX merge_requests_recent;
6CREATE INDEX issues_recent ON issues(updated_at DESC, id DESC);
7CREATE INDEX merge_requests_recent ON merge_requests(updated_at DESC, id DESC);