From 240d0efa7e3860dfde0eb94932287602cbfae533 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 17 Jun 2026 22:37:55 +0200 Subject: [PATCH] perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `created_unix` as the third column of the `c_u` composite index on the `action` table, changing it from `(user_id, is_deleted)` to `(user_id, is_deleted, created_unix)`. Migration 337 drops and recreates the index. No data is touched. ## Root causes #32333 introduced the `c_u` index to speed up dashboard queries, but defined it as `(user_id, is_deleted)` — without `created_unix`. #3368 The simple query is now efficient enough for the database to actually use `c_u`, but because `created_unix` is absent from the index, the database must load and sort **every** matching row before returning the first page of 20. The existing `c_u_d` index `(created_unix, user_id, is_deleted)` does not help because its leading column is `created_unix`, which can't be used for an equality seek on `user_id`. Those two caused this issue: https://github.com/go-gitea/gitea/issues/38075 With the fix, the database seeks directly to `(user_id=X, is_deleted=false)` and walks `created_unix` in descending order, stopping after 20 rows. Fixes https://github.com/go-gitea/gitea/issues/38075 --- models/activities/action.go | 2 +- models/migrations/migrations.go | 1 + models/migrations/v1_27/v339.go | 40 ++++++++++++++++ models/migrations/v1_27/v339_test.go | 72 ++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 models/migrations/v1_27/v339.go create mode 100644 models/migrations/v1_27/v339_test.go diff --git a/models/activities/action.go b/models/activities/action.go index a666c31c27..52e733b8db 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -166,7 +166,7 @@ func (a *Action) TableIndices() []*schemas.Index { cudIndex.AddColumn("created_unix", "user_id", "is_deleted") cuIndex := schemas.NewIndex("c_u", schemas.IndexType) - cuIndex.AddColumn("user_id", "is_deleted") + cuIndex.AddColumn("user_id", "is_deleted", "created_unix") actUserUserIndex := schemas.NewIndex("au_c_u", schemas.IndexType) actUserUserIndex.AddColumn("act_user_id", "created_unix", "user_id") diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index be7b333a2e..a026ee7a52 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -416,6 +416,7 @@ func prepareMigrationTasks() []*migration { newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam), newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL), + newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex), } return preparedMigrations } diff --git a/models/migrations/v1_27/v339.go b/models/migrations/v1_27/v339.go new file mode 100644 index 0000000000..5499cfbc22 --- /dev/null +++ b/models/migrations/v1_27/v339.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + + "gitea.dev/models/db" + + "xorm.io/xorm/schemas" +) + +// AddCreatedUnixToActionUserIsDeletedIndex extends the c_u composite index on +// the action table to include created_unix, enabling efficient ORDER BY on the +// dashboard feed query without a full sort of all matching rows. +func AddCreatedUnixToActionUserIsDeletedIndex(x db.EngineMigration) error { + // xorm Sync cannot reliably update an index when another index already + // covers the same columns in a different order (Equal() is order-insensitive). + // Drop the old c_u index explicitly, then recreate it with the new column set. + indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action") + if err != nil { + return err + } + for _, idx := range indexes { + if idx.Name == "c_u" { + if _, err := x.Exec(x.Dialect().DropIndexSQL("action", idx)); err != nil { + return err + } + break + } + } + + newIndex := schemas.NewIndex("c_u", schemas.IndexType) + newIndex.AddColumn("user_id", "is_deleted", "created_unix") + if _, err := x.Exec(x.Dialect().CreateIndexSQL("action", newIndex)); err != nil { + return err + } + return nil +} diff --git a/models/migrations/v1_27/v339_test.go b/models/migrations/v1_27/v339_test.go new file mode 100644 index 0000000000..b41d3d6476 --- /dev/null +++ b/models/migrations/v1_27/v339_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + "testing" + + "gitea.dev/models/migrations/migrationtest" + "gitea.dev/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +type actionBeforeV339 struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX"` + OpType int + ActUserID int64 + RepoID int64 + CommentID int64 `xorm:"INDEX"` + IsDeleted bool `xorm:"NOT NULL DEFAULT false"` + RefName string + IsPrivate bool `xorm:"NOT NULL DEFAULT false"` + Content string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +func (actionBeforeV339) TableName() string { return "action" } + +func (actionBeforeV339) TableIndices() []*schemas.Index { + repoIndex := schemas.NewIndex("r_u_d", schemas.IndexType) + repoIndex.AddColumn("repo_id", "user_id", "is_deleted") + + actUserIndex := schemas.NewIndex("au_r_c_u_d", schemas.IndexType) + actUserIndex.AddColumn("act_user_id", "repo_id", "created_unix", "user_id", "is_deleted") + + cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType) + cudIndex.AddColumn("created_unix", "user_id", "is_deleted") + + // old 2-column index, before the migration + cuIndex := schemas.NewIndex("c_u", schemas.IndexType) + cuIndex.AddColumn("user_id", "is_deleted") + + actUserUserIndex := schemas.NewIndex("au_c_u", schemas.IndexType) + actUserUserIndex.AddColumn("act_user_id", "created_unix", "user_id") + + return []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex, actUserUserIndex} +} + +func Test_AddCreatedUnixToActionUserIsDeletedIndex(t *testing.T) { + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(actionBeforeV339)) + defer deferable() + if x == nil || t.Failed() { + return + } + + indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted"}, false), "old c_u index should exist before migration") + assert.False(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted", "created_unix"}, false), "new c_u index should not exist before migration") + + require.NoError(t, AddCreatedUnixToActionUserIsDeletedIndex(x)) + + indexes, err = x.Dialect().GetIndexes(x.DB(), context.Background(), "action") + require.NoError(t, err) + assert.False(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted"}, false), "old 2-column c_u index should be gone after migration") + assert.True(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted", "created_unix"}, false), "new 3-column c_u index must exist after migration") +}