refactor(tasks): drop in-memory task dedup, rely on unique index

The duplicate task rows getTasksForProjects deduplicated came from the
LEFT JOIN multiplying when duplicate task_positions rows existed. The new
unique index on (task_id, project_view_id) removes the root cause at the
SQL layer (the migration also runs before serving), so the join can no
longer multiply. Revert getTasksForProjects and getRawTasksForProjects to
their pre-dedup shape.
This commit is contained in:
kolaente 2026-06-17 22:55:37 +02:00
parent efb103ce0f
commit 822554cf30
5 changed files with 11 additions and 27 deletions

View File

@ -257,7 +257,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, projects []*Pr
} }
} }
ts, total, err := getRawTasksForProjects(s, projects, auth, opts) ts, _, total, err := getRawTasksForProjects(s, projects, auth, opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -1370,7 +1370,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
// Delete all tasks on that project // Delete all tasks on that project
// Using the loop to make sure all related entities to all tasks are properly deleted as well. // Using the loop to make sure all related entities to all tasks are properly deleted as well.
tasks, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{}) tasks, _, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{})
if err != nil { if err != nil {
return return
} }

View File

@ -1898,7 +1898,7 @@ func TestTaskSearchWithExpandSubtasks(t *testing.T) {
expand: []TaskCollectionExpandable{TaskCollectionExpandSubtasks}, expand: []TaskCollectionExpandable{TaskCollectionExpandSubtasks},
} }
tasks, _, err := getRawTasksForProjects(s, []*Project{project}, &user.User{ID: 15}, opts) tasks, _, _, err := getRawTasksForProjects(s, []*Project{project}, &user.User{ID: 15}, opts)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, tasks) require.NotEmpty(t, tasks)
} }

View File

@ -139,7 +139,7 @@ func BenchmarkTaskSearch(b *testing.B) {
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
s := db.NewSession() s := db.NewSession()
resultSlice, _, err := getRawTasksForProjects(s, projects, auth, opts) resultSlice, _, _, err := getRawTasksForProjects(s, projects, auth, opts)
if len(resultSlice) == 0 { if len(resultSlice) == 0 {
b.Fatalf("no results found for needle %q", needle) b.Fatalf("no results found for needle %q", needle)
} }

View File

@ -288,11 +288,11 @@ func getTaskIndexFromSearchString(s string) (index int64) {
return return
} }
func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, totalItems int64, err error) { func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
// If the user does not have any projects, don't try to get any tasks // If the user does not have any projects, don't try to get any tasks
if len(projects) == 0 { if len(projects) == 0 {
return nil, 0, nil return nil, 0, 0, nil
} }
// Get all project IDs and get the tasks // Get all project IDs and get the tasks
@ -324,18 +324,17 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op
} }
tasks, totalItems, err = dbSearcher.Search(opts) tasks, totalItems, err = dbSearcher.Search(opts)
return tasks, totalItems, err return tasks, len(tasks), totalItems, err
} }
func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions, view *ProjectView) (tasks []*Task, resultCount int, totalItems int64, err error) { func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions, view *ProjectView) (tasks []*Task, resultCount int, totalItems int64, err error) {
tasks, totalItems, err = getRawTasksForProjects(s, projects, a, opts) tasks, resultCount, totalItems, err = getRawTasksForProjects(s, projects, a, opts)
if err != nil { if err != nil {
return nil, 0, 0, err return nil, 0, 0, err
} }
rawTasks := tasks taskMap := make(map[int64]*Task, len(tasks))
taskMap := make(map[int64]*Task, len(rawTasks)) for _, t := range tasks {
for _, t := range rawTasks {
taskMap[t.ID] = t taskMap[t.ID] = t
} }
@ -344,22 +343,7 @@ func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts
return nil, 0, 0, err return nil, 0, 0, err
} }
// A task can appear more than once in the raw result when it has duplicate return tasks, resultCount, totalItems, err
// task_positions rows for the view (the LEFT JOIN multiplies it). Return one
// entry per task, in the original sort order, referencing the enriched map
// value so its identifier and other data are set. totalItems already counts
// distinct tasks, so this also aligns the page size with it.
tasks = make([]*Task, 0, len(taskMap))
seen := make(map[int64]bool, len(taskMap))
for _, t := range rawTasks {
if seen[t.ID] {
continue
}
seen[t.ID] = true
tasks = append(tasks, taskMap[t.ID])
}
return tasks, len(tasks), totalItems, err
} }
// GetTaskByIDSimple returns a raw task without extra data by the task ID // GetTaskByIDSimple returns a raw task without extra data by the task ID