From 4405fb0bb716b7cfdeb1ebdfdf4d4e101a41e0e3 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 11 Jun 2026 17:27:50 +0200 Subject: [PATCH] fix: extend action c_u index to include created_unix for faster dashboard feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The c_u index on the action table was defined as (user_id, is_deleted) without created_unix. The dashboard feed query filters by those two columns and then orders by created_unix DESC, so the database had to load and sort every matching row before it could return the first page of 20 — causing 27+ second queries on large action tables. Adds created_unix as the third column of the c_u index so the database can seek to (user_id, is_deleted) and walk created_unix in reverse order, stopping after 20 rows without a full sort. Assisted-by: Claude:claude-sonnet-4-6 --- models/activities/action.go | 2 +- models/migrations/migrations.go | 1 + models/migrations/v1_27/v337.go | 61 +++++++++++++++++++++++ models/migrations/v1_27/v337_test.go | 72 ++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 models/migrations/v1_27/v337.go create mode 100644 models/migrations/v1_27/v337_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 aedd679c57..21825d502b 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -414,6 +414,7 @@ func prepareMigrationTasks() []*migration { newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner), newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob), newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), + newMigration(337, "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/v337.go b/models/migrations/v1_27/v337.go new file mode 100644 index 0000000000..e55c749e29 --- /dev/null +++ b/models/migrations/v1_27/v337.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "gitea.dev/models/db" + "gitea.dev/modules/timeutil" + + "xorm.io/xorm/schemas" +) + +// actionWithUpdatedIndex is a minimal mirror of the action table used to apply +// the updated c_u composite index (user_id, is_deleted, created_unix). +// The previous index only covered (user_id, is_deleted), which forced the +// database to sort all matching rows by created_unix before returning a page, +// causing multi-second query times on large action tables. +type actionWithUpdatedIndex 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 (actionWithUpdatedIndex) TableName() string { return "action" } + +func (actionWithUpdatedIndex) 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") + + // Extended from (user_id, is_deleted) to include created_unix so that + // ORDER BY created_unix DESC on the dashboard query is satisfied by the + // index without a full sort of all matching rows. + cuIndex := schemas.NewIndex("c_u", schemas.IndexType) + 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") + + return []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex, actUserUserIndex} +} + +// 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 { + return x.Sync(new(actionWithUpdatedIndex)) +} diff --git a/models/migrations/v1_27/v337_test.go b/models/migrations/v1_27/v337_test.go new file mode 100644 index 0000000000..ab7fe9230d --- /dev/null +++ b/models/migrations/v1_27/v337_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 actionBeforeV337 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 (actionBeforeV337) TableName() string { return "action" } + +func (actionBeforeV337) 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(actionBeforeV337)) + 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") +}