fix: reduce deadlocks merging PRs w/ async milestone stat recalcs (#9916)

Continuing the pattern from #9868, fixes another deadlock discovered in synthetic testing of #9785.  This modifies the `milestone` table to have the `num_issues`, `num_closed_issues`, and `completeness` statistics be calculated asynchronously.

An optional `updateTimestamp` field was added to the stats queue to support the conditional updating of the milestone's modification date, retaining existing functionality.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9916
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2025-10-31 15:53:45 +01:00 committed by Mathieu Fenniak
parent 0869e0e08a
commit 327cdc1787
14 changed files with 196 additions and 64 deletions

View file

@ -111,11 +111,11 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use
// Update issue count of milestone
if issue.MilestoneID > 0 {
if issue.NoAutoTime {
if err := UpdateMilestoneCountersWithDate(ctx, issue.MilestoneID, issue.UpdatedUnix); err != nil {
if err := stats.QueueRecalcMilestoneByIDWithDate(issue.MilestoneID, issue.UpdatedUnix); err != nil {
return nil, err
}
} else {
if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil {
if err := stats.QueueRecalcMilestoneByID(issue.MilestoneID); err != nil {
return nil, err
}
}
@ -353,7 +353,7 @@ func NewIssueWithIndex(ctx context.Context, doer *user_model.User, opts NewIssue
}
if opts.Issue.MilestoneID > 0 {
if err := UpdateMilestoneCounters(ctx, opts.Issue.MilestoneID); err != nil {
if err := stats.QueueRecalcMilestoneByID(opts.Issue.MilestoneID); err != nil {
return err
}

View file

@ -521,11 +521,11 @@ func init() {
stats.RegisterRecalc(stats.LabelByRepoID, doRecalcLabelByRepoID)
}
func doRecalcLabelByID(ctx context.Context, labelID int64) error {
func doRecalcLabelByID(ctx context.Context, labelID int64, _ optional.Option[timeutil.TimeStamp]) error {
return doRecalcLabel(ctx, builder.Eq{"id": labelID})
}
func doRecalcLabelByRepoID(ctx context.Context, repoID int64) error {
func doRecalcLabelByRepoID(ctx context.Context, repoID int64, _ optional.Option[timeutil.TimeStamp]) error {
return doRecalcLabel(ctx, builder.Eq{"repo_id": repoID})
}

View file

@ -8,6 +8,8 @@ import (
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -18,7 +20,7 @@ func TestRecalcLabelByLabelID(t *testing.T) {
// Verify no error on recalc of a deleted/non-existent object; important because async recalcs can be queued and
// then occur later after more state changes have happened.
err := doRecalcLabelByID(t.Context(), -1000)
err := doRecalcLabelByID(t.Context(), -1000, optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
// Intentionally corrupt counts from fixture, then recalc them
@ -29,7 +31,7 @@ func TestRecalcLabelByLabelID(t *testing.T) {
Update(map[string]any{"num_issues": 1000, "num_closed_issues": 1001})
require.NoError(t, err)
require.EqualValues(t, 1, updated)
err = doRecalcLabelByID(t.Context(), label.ID)
err = doRecalcLabelByID(t.Context(), label.ID, optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
label = unittest.AssertExistsAndLoadBean(t, &Label{ID: 1})
assert.Equal(t, 2, label.NumIssues)
@ -41,7 +43,7 @@ func TestRecalcLabelByRepoID(t *testing.T) {
// Verify no error on recalc of a deleted/non-existent object; important because async recalcs can be queued and
// then occur later after more state changes have happened.
err := doRecalcLabelByRepoID(t.Context(), -1000)
err := doRecalcLabelByRepoID(t.Context(), -1000, optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
// Intentionally corrupt counts from fixture, then recalc them
@ -60,7 +62,7 @@ func TestRecalcLabelByRepoID(t *testing.T) {
Update(map[string]any{"num_issues": 1000, "num_closed_issues": 1001})
require.NoError(t, err)
require.EqualValues(t, 1, updated)
err = doRecalcLabelByRepoID(t.Context(), label1.RepoID)
err = doRecalcLabelByRepoID(t.Context(), label1.RepoID, optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
label1 = unittest.AssertExistsAndLoadBean(t, &Label{ID: 1})
label2 = unittest.AssertExistsAndLoadBean(t, &Label{ID: 2})

View file

@ -15,6 +15,7 @@ import (
api "forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
"forgejo.org/services/stats"
"xorm.io/builder"
)
@ -193,42 +194,7 @@ func updateMilestone(ctx context.Context, m *Milestone) error {
if err != nil {
return err
}
return UpdateMilestoneCounters(ctx, m.ID)
}
func updateMilestoneCounters(ctx context.Context, id int64, noAutoTime bool, updatedUnix timeutil.TimeStamp) error {
e := db.GetEngine(ctx)
sess := e.ID(id).
SetExpr("num_issues", builder.Select("count(*)").From("issue").Where(
builder.Eq{"milestone_id": id},
)).
SetExpr("num_closed_issues", builder.Select("count(*)").From("issue").Where(
builder.Eq{
"milestone_id": id,
"is_closed": true,
},
))
if noAutoTime {
sess.SetExpr("updated_unix", updatedUnix).NoAutoTime()
}
_, err := sess.Update(&Milestone{})
if err != nil {
return err
}
_, err = e.Exec("UPDATE `milestone` SET completeness=100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) WHERE id=?",
id,
)
return err
}
// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness
func UpdateMilestoneCounters(ctx context.Context, id int64) error {
return updateMilestoneCounters(ctx, id, false, 0)
}
// UpdateMilestoneCountersWithDate calculates NumIssues, NumClosesIssues and Completeness and set the UpdatedUnix date
func UpdateMilestoneCountersWithDate(ctx context.Context, id int64, updatedUnix timeutil.TimeStamp) error {
return updateMilestoneCounters(ctx, id, true, updatedUnix)
return stats.QueueRecalcMilestoneByID(m.ID)
}
// ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo.
@ -384,3 +350,46 @@ func InsertMilestones(ctx context.Context, ms ...*Milestone) (err error) {
}
return committer.Commit()
}
func init() {
stats.RegisterRecalc(stats.MilestoneByMilestoneID, doRecalcMilestoneByID)
}
func doRecalcMilestoneByID(ctx context.Context, milestoneID int64, updateTimestamp optional.Option[timeutil.TimeStamp]) error {
return doRecalcMilestone(ctx, builder.Eq{"id": milestoneID}, updateTimestamp)
}
func doRecalcMilestone(ctx context.Context, cond builder.Cond, updateTimestamp optional.Option[timeutil.TimeStamp]) error {
return db.WithTx(ctx, func(ctx context.Context) error {
e := db.GetEngine(ctx)
sess := e.
SetExpr("num_issues",
builder.Select("count(*)").From("issue").
Where(builder.Eq{"milestone_id": builder.Expr("milestone.id")}),
).
SetExpr("num_closed_issues",
builder.Select("count(*)").
From("issue").
Where(builder.Eq{
"issue.milestone_id": builder.Expr("milestone.id"),
"issue.is_closed": true,
}),
).
Where(cond)
if updateTimestamp.Has() {
sess.SetExpr("updated_unix", updateTimestamp.Value()).NoAutoTime()
}
_, err := sess.Update(&Milestone{})
if err != nil {
return err
}
_, err = e.
SetExpr("completeness", "100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END)").
Where(cond).
NoAutoTime(). // don't change time from earlier UPDATE
Update(&Milestone{})
return err
})
}

View file

@ -0,0 +1,66 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package issues
import (
"testing"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRecalcMilestoneByMilestoneID(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// Verify no error on recalc of a deleted/non-existent object; important because async recalcs can be queued and
// then occur later after more state changes have happened.
err := doRecalcMilestoneByID(t.Context(), -1000, optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
// Intentionally corrupt counts from fixture, then recalc them
milestone := unittest.AssertExistsAndLoadBean(t, &Milestone{ID: 1})
updated, err := db.GetEngine(t.Context()).
Table(&Milestone{}).
Where("id = ?", milestone.ID).
Update(map[string]any{
"num_issues": 1000,
"num_closed_issues": 1001,
"completeness": 99,
"updated_unix": 123,
})
require.NoError(t, err)
require.EqualValues(t, 1, updated)
err = doRecalcMilestoneByID(t.Context(), milestone.ID, optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
milestone = unittest.AssertExistsAndLoadBean(t, &Milestone{ID: 1})
assert.Equal(t, 1, milestone.NumIssues)
assert.Equal(t, 0, milestone.NumClosedIssues)
assert.Equal(t, 0, milestone.Completeness)
assert.NotEqualValues(t, 123, milestone.UpdatedUnix)
// Exercise the updateTimestamp option to the recalc
updated, err = db.GetEngine(t.Context()).
Table(&Milestone{}).
Where("id = ?", milestone.ID).
Update(map[string]any{
"num_issues": 1000,
"num_closed_issues": 1001,
"completeness": 99,
"updated_unix": 123,
})
require.NoError(t, err)
require.EqualValues(t, 1, updated)
err = doRecalcMilestoneByID(t.Context(), milestone.ID, optional.Some(timeutil.TimeStamp(456)))
require.NoError(t, err)
milestone = unittest.AssertExistsAndLoadBean(t, &Milestone{ID: 1})
assert.Equal(t, 1, milestone.NumIssues)
assert.Equal(t, 0, milestone.NumClosedIssues)
assert.Equal(t, 0, milestone.Completeness)
assert.EqualValues(t, 456, milestone.UpdatedUnix)
}

View file

@ -15,6 +15,7 @@ import (
"forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
"forgejo.org/services/stats"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -340,14 +341,16 @@ func TestUpdateMilestoneCounters(t *testing.T) {
issue.ClosedUnix = timeutil.TimeStampNow()
_, err := db.GetEngine(db.DefaultContext).ID(issue.ID).Cols("is_closed", "closed_unix").Update(issue)
require.NoError(t, err)
require.NoError(t, issues_model.UpdateMilestoneCounters(db.DefaultContext, issue.MilestoneID))
err = stats.QueueRecalcMilestoneByID(issue.MilestoneID)
require.NoError(t, err)
unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
issue.IsClosed = false
issue.ClosedUnix = 0
_, err = db.GetEngine(db.DefaultContext).ID(issue.ID).Cols("is_closed", "closed_unix").Update(issue)
require.NoError(t, err)
require.NoError(t, issues_model.UpdateMilestoneCounters(db.DefaultContext, issue.MilestoneID))
err = stats.QueueRecalcMilestoneByID(issue.MilestoneID)
require.NoError(t, err)
unittest.CheckConsistencyFor(t, &issues_model.Milestone{})
}

View file

@ -98,8 +98,7 @@ func milestoneStatsCorrectNumIssuesRepo(ctx context.Context, id int64) error {
}
for _, result := range results {
id, _ := strconv.ParseInt(string(result["id"]), 10, 64)
err = issues_model.UpdateMilestoneCounters(ctx, id)
if err != nil {
if err := stats.QueueRecalcMilestoneByID(id); err != nil {
return err
}
}
@ -195,7 +194,9 @@ func CheckRepoStats(ctx context.Context) error {
// Milestone.Num{,Closed}Issues
{
statsQuery(milestoneStatsQueryNumIssues, true),
issues_model.UpdateMilestoneCounters,
func(ctx context.Context, milestoneID int64) error {
return stats.QueueRecalcMilestoneByID(milestoneID)
},
"milestone count 'num_closed_issues' and 'num_issues'",
},
// User.NumRepos