mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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:
parent
0869e0e08a
commit
327cdc1787
14 changed files with 196 additions and 64 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
66
models/issues/milestone_internal_test.go
Normal file
66
models/issues/milestone_internal_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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{})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue