From 327cdc1787c19e35ca54c4babf12b185364a6320 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Fri, 31 Oct 2025 15:53:45 +0100 Subject: [PATCH] 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/.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 Co-authored-by: Mathieu Fenniak Co-committed-by: Mathieu Fenniak --- models/issues/issue_update.go | 6 +- models/issues/label.go | 4 +- models/issues/label_internal_test.go | 10 +-- models/issues/milestone.go | 81 +++++++++++++----------- models/issues/milestone_internal_test.go | 66 +++++++++++++++++++ models/issues/milestone_test.go | 7 +- models/repo.go | 7 +- services/issue/issue.go | 3 +- services/issue/milestone.go | 5 +- services/stats/milestone.go | 25 ++++++++ services/stats/recalc_request.go | 27 ++++++-- services/stats/stats.go | 7 +- services/stats/stats_test.go | 9 ++- tests/integration/api_issue_test.go | 3 + 14 files changed, 196 insertions(+), 64 deletions(-) create mode 100644 models/issues/milestone_internal_test.go create mode 100644 services/stats/milestone.go diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 0c503526d0..2a6e7f8f65 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -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 } diff --git a/models/issues/label.go b/models/issues/label.go index bf70630f51..4eefae0d5d 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -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}) } diff --git a/models/issues/label_internal_test.go b/models/issues/label_internal_test.go index 42289b9692..e929f74f5e 100644 --- a/models/issues/label_internal_test.go +++ b/models/issues/label_internal_test.go @@ -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}) diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 67a23246cf..cd3fcb3147 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -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 + }) +} diff --git a/models/issues/milestone_internal_test.go b/models/issues/milestone_internal_test.go new file mode 100644 index 0000000000..d64d9f47e9 --- /dev/null +++ b/models/issues/milestone_internal_test.go @@ -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) +} diff --git a/models/issues/milestone_test.go b/models/issues/milestone_test.go index 7391cc0894..c1f945658f 100644 --- a/models/issues/milestone_test.go +++ b/models/issues/milestone_test.go @@ -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{}) } diff --git a/models/repo.go b/models/repo.go index 76385db65a..d25e4180a0 100644 --- a/models/repo.go +++ b/models/repo.go @@ -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 diff --git a/services/issue/issue.go b/services/issue/issue.go index 24972c8f7a..72463e8179 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -23,6 +23,7 @@ import ( "forgejo.org/modules/storage" "forgejo.org/modules/timeutil" notify_service "forgejo.org/services/notify" + "forgejo.org/services/stats" ) // NewIssue creates new issue with labels for repository. @@ -303,7 +304,7 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { } } - if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { + if err := stats.QueueRecalcMilestoneByID(issue.MilestoneID); err != nil { return fmt.Errorf("error updating counters for milestone id %d: %w", issue.MilestoneID, err) } diff --git a/services/issue/milestone.go b/services/issue/milestone.go index a561bf8eee..ca83fe9c76 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -12,6 +12,7 @@ import ( issues_model "forgejo.org/models/issues" user_model "forgejo.org/models/user" notify_service "forgejo.org/services/notify" + "forgejo.org/services/stats" ) func updateMilestoneCounters(ctx context.Context, issue *issues_model.Issue, id int64) error { @@ -29,11 +30,11 @@ func updateMilestoneCounters(ctx context.Context, issue *issues_model.Issue, id if issue.UpdatedUnix > updatedUnix { updatedUnix = issue.UpdatedUnix } - if err := issues_model.UpdateMilestoneCountersWithDate(ctx, id, updatedUnix); err != nil { + if err := stats.QueueRecalcMilestoneByIDWithDate(id, updatedUnix); err != nil { return err } } else { - if err := issues_model.UpdateMilestoneCounters(ctx, id); err != nil { + if err := stats.QueueRecalcMilestoneByID(id); err != nil { return err } } diff --git a/services/stats/milestone.go b/services/stats/milestone.go new file mode 100644 index 0000000000..af7537cfd3 --- /dev/null +++ b/services/stats/milestone.go @@ -0,0 +1,25 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package stats + +import ( + "forgejo.org/modules/optional" + "forgejo.org/modules/timeutil" +) + +// Queue a recalculation of the stats on a `Milestone` for a given milestone by its ID +func QueueRecalcMilestoneByID(labelID int64) error { + return safePush(recalcRequest{ + RecalcType: MilestoneByMilestoneID, + ObjectID: labelID, + }) +} + +func QueueRecalcMilestoneByIDWithDate(labelID int64, updateTimestamp timeutil.TimeStamp) error { + return safePush(recalcRequest{ + RecalcType: MilestoneByMilestoneID, + ObjectID: labelID, + UpdateTimestamp: optional.Some(updateTimestamp), + }) +} diff --git a/services/stats/recalc_request.go b/services/stats/recalc_request.go index 54885f3ef6..4b4a57b4e9 100644 --- a/services/stats/recalc_request.go +++ b/services/stats/recalc_request.go @@ -8,20 +8,24 @@ import ( "fmt" "strconv" "strings" + + "forgejo.org/modules/optional" + "forgejo.org/modules/timeutil" ) type recalcRequest struct { - RecalcType RecalcType - ObjectID int64 + RecalcType RecalcType + ObjectID int64 + UpdateTimestamp optional.Option[timeutil.TimeStamp] } func (r *recalcRequest) string() string { - return fmt.Sprintf("recalcRequest:%d:%d", r.RecalcType, r.ObjectID) + return fmt.Sprintf("recalcRequest:%d:%d:%d", r.RecalcType, r.ObjectID, r.UpdateTimestamp.ValueOrDefault(0)) } func recalcRequestFromString(s string) (*recalcRequest, error) { tags := strings.Split(s, ":") - if len(tags) != 3 { + if len(tags) != 4 { return nil, errors.New("expected three tags") } else if tags[0] != "recalcRequest" { return nil, fmt.Errorf("expected tag `recalcRequest`, but was %s", tags[0]) @@ -34,8 +38,19 @@ func recalcRequestFromString(s string) (*recalcRequest, error) { if err != nil { return nil, fmt.Errorf("unable to parse object ID: %w", err) } + timestamp, err := strconv.ParseInt(tags[3], 10, 64) + if err != nil { + return nil, fmt.Errorf("unable to parse timestamp ID: %w", err) + } + var updateTimestamp optional.Option[timeutil.TimeStamp] + if timestamp == 0 { + updateTimestamp = optional.None[timeutil.TimeStamp]() + } else { + updateTimestamp = optional.Some(timeutil.TimeStamp(timestamp)) + } return &recalcRequest{ - RecalcType: RecalcType(recalcType), - ObjectID: objectID, + RecalcType: RecalcType(recalcType), + ObjectID: objectID, + UpdateTimestamp: updateTimestamp, }, nil } diff --git a/services/stats/stats.go b/services/stats/stats.go index dcc59ed72d..7699013b37 100644 --- a/services/stats/stats.go +++ b/services/stats/stats.go @@ -43,7 +43,9 @@ import ( "forgejo.org/modules/graceful" "forgejo.org/modules/log" + "forgejo.org/modules/optional" "forgejo.org/modules/queue" + "forgejo.org/modules/timeutil" ) type RecalcType int @@ -51,9 +53,10 @@ type RecalcType int const ( LabelByLabelID RecalcType = iota LabelByRepoID + MilestoneByMilestoneID ) -type RecalcHandler func(context.Context, int64) error +type RecalcHandler func(context.Context, int64, optional.Option[timeutil.TimeStamp]) error var ( // string queue is used for consistent unique behaviour independent of json serialization @@ -99,7 +102,7 @@ func handler(items ...string) []string { log.Error("Unrecognized RecalcType %d, ignoring", req.RecalcType) continue } - if err := handler(ctx, req.ObjectID); err != nil { + if err := handler(ctx, req.ObjectID, req.UpdateTimestamp); err != nil { log.Error("Error in stats recalc %v on object %d: %v", req.RecalcType, req.ObjectID, err) } } diff --git a/services/stats/stats_test.go b/services/stats/stats_test.go index 91e3d30813..d070d2145f 100644 --- a/services/stats/stats_test.go +++ b/services/stats/stats_test.go @@ -9,6 +9,9 @@ import ( "sync" "testing" + "forgejo.org/modules/optional" + "forgejo.org/modules/timeutil" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,7 +19,7 @@ import ( func TestQueueAndFlush(t *testing.T) { var mu sync.Mutex callValues := []int64{} - RegisterRecalc(-99, func(ctx context.Context, i int64) error { + RegisterRecalc(-99, func(ctx context.Context, i int64, _ optional.Option[timeutil.TimeStamp]) error { mu.Lock() defer mu.Unlock() callValues = append(callValues, i) @@ -41,7 +44,7 @@ func TestQueueAndFlush(t *testing.T) { func TestQueueUnique(t *testing.T) { var mu sync.Mutex callValues := []int64{} - RegisterRecalc(-100, func(ctx context.Context, i int64) error { + RegisterRecalc(-100, func(ctx context.Context, i int64, _ optional.Option[timeutil.TimeStamp]) error { mu.Lock() defer mu.Unlock() callValues = append(callValues, i) @@ -72,7 +75,7 @@ func TestQueueUnique(t *testing.T) { func TestQueueAndError(t *testing.T) { var mu sync.Mutex callValues := []int64{} - RegisterRecalc(-101, func(ctx context.Context, i int64) error { + RegisterRecalc(-101, func(ctx context.Context, i int64, _ optional.Option[timeutil.TimeStamp]) error { mu.Lock() defer mu.Unlock() callValues = append(callValues, i) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index e8ea447463..dc369e93b9 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -412,6 +412,7 @@ func TestAPIEditIssueMilestoneAutoDate(t *testing.T) { Milestone: &milestone, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) + unittest.FlushAsyncCalcs(t) // the execution of the API call supposedly lasted less than one minute milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) @@ -431,6 +432,7 @@ func TestAPIEditIssueMilestoneAutoDate(t *testing.T) { Updated: &updatedAt, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) + unittest.FlushAsyncCalcs(t) // the milestone date should be set to 'updatedAt' // dates are converted into the same tz, in order to compare them @@ -452,6 +454,7 @@ func TestAPIEditIssueMilestoneAutoDate(t *testing.T) { Updated: &updatedAt, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) + unittest.FlushAsyncCalcs(t) // the milestone date should not change // dates are converted into the same tz, in order to compare them