Skip to content

Commit c36a1bc

Browse files
lunnysilverwind
andauthored
Fix parallel creating commit status bug with tests (#21911) (#21989)
backport #21911 backport #21998 Co-authored-by: silverwind <me@silverwind.io>
1 parent 079ef56 commit c36a1bc

File tree

3 files changed

+101
-65
lines changed

3 files changed

+101
-65
lines changed

models/db/index.go

+20-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import (
88
"context"
99
"errors"
1010
"fmt"
11+
"strconv"
12+
13+
"code.gitea.io/gitea/modules/setting"
1114
)
1215

1316
// ResourceIndex represents a resource index which could be used as issue/release and others
@@ -24,11 +27,6 @@ var (
2427
ErrGetResourceIndexFailed = errors.New("get resource index failed")
2528
)
2629

27-
const (
28-
// MaxDupIndexAttempts max retry times to create index
29-
MaxDupIndexAttempts = 3
30-
)
31-
3230
// SyncMaxResourceIndex sync the max index with the resource
3331
func SyncMaxResourceIndex(ctx context.Context, tableName string, groupID, maxIndex int64) (err error) {
3432
e := GetEngine(ctx)
@@ -61,8 +59,25 @@ func SyncMaxResourceIndex(ctx context.Context, tableName string, groupID, maxInd
6159
return nil
6260
}
6361

62+
func postgresGetNextResourceIndex(ctx context.Context, tableName string, groupID int64) (int64, error) {
63+
res, err := GetEngine(ctx).Query(fmt.Sprintf("INSERT INTO %s (group_id, max_index) "+
64+
"VALUES (?,1) ON CONFLICT (group_id) DO UPDATE SET max_index = %s.max_index+1 RETURNING max_index",
65+
tableName, tableName), groupID)
66+
if err != nil {
67+
return 0, err
68+
}
69+
if len(res) == 0 {
70+
return 0, ErrGetResourceIndexFailed
71+
}
72+
return strconv.ParseInt(string(res[0]["max_index"]), 10, 64)
73+
}
74+
6475
// GetNextResourceIndex generates a resource index, it must run in the same transaction where the resource is created
6576
func GetNextResourceIndex(ctx context.Context, tableName string, groupID int64) (int64, error) {
77+
if setting.Database.UsePostgreSQL {
78+
return postgresGetNextResourceIndex(ctx, tableName, groupID)
79+
}
80+
6681
e := GetEngine(ctx)
6782

6883
// try to update the max_index to next value, and acquire the write-lock for the record

models/git/commit_status.go

+50-60
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ package git
77
import (
88
"context"
99
"crypto/sha1"
10+
"errors"
1011
"fmt"
1112
"net/url"
13+
"strconv"
1214
"strings"
1315
"time"
1416

@@ -49,79 +51,67 @@ func init() {
4951
db.RegisterModel(new(CommitStatusIndex))
5052
}
5153

52-
// upsertCommitStatusIndex the function will not return until it acquires the lock or receives an error.
53-
func upsertCommitStatusIndex(ctx context.Context, repoID int64, sha string) (err error) {
54-
// An atomic UPSERT operation (INSERT/UPDATE) is the only operation
55-
// that ensures that the key is actually locked.
56-
switch {
57-
case setting.Database.UseSQLite3 || setting.Database.UsePostgreSQL:
58-
_, err = db.Exec(ctx, "INSERT INTO `commit_status_index` (repo_id, sha, max_index) "+
59-
"VALUES (?,?,1) ON CONFLICT (repo_id,sha) DO UPDATE SET max_index = `commit_status_index`.max_index+1",
60-
repoID, sha)
61-
case setting.Database.UseMySQL:
62-
_, err = db.Exec(ctx, "INSERT INTO `commit_status_index` (repo_id, sha, max_index) "+
63-
"VALUES (?,?,1) ON DUPLICATE KEY UPDATE max_index = max_index+1",
64-
repoID, sha)
65-
case setting.Database.UseMSSQL:
66-
// https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/
67-
_, err = db.Exec(ctx, "MERGE `commit_status_index` WITH (HOLDLOCK) as target "+
68-
"USING (SELECT ? AS repo_id, ? AS sha) AS src "+
69-
"ON src.repo_id = target.repo_id AND src.sha = target.sha "+
70-
"WHEN MATCHED THEN UPDATE SET target.max_index = target.max_index+1 "+
71-
"WHEN NOT MATCHED THEN INSERT (repo_id, sha, max_index) "+
72-
"VALUES (src.repo_id, src.sha, 1);",
73-
repoID, sha)
74-
default:
75-
return fmt.Errorf("database type not supported")
54+
func postgresGetCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) {
55+
res, err := db.GetEngine(ctx).Query("INSERT INTO `commit_status_index` (repo_id, sha, max_index) "+
56+
"VALUES (?,?,1) ON CONFLICT (repo_id, sha) DO UPDATE SET max_index = `commit_status_index`.max_index+1 RETURNING max_index",
57+
repoID, sha)
58+
if err != nil {
59+
return 0, err
60+
}
61+
if len(res) == 0 {
62+
return 0, db.ErrGetResourceIndexFailed
7663
}
77-
return err
64+
return strconv.ParseInt(string(res[0]["max_index"]), 10, 64)
7865
}
7966

8067
// GetNextCommitStatusIndex retried 3 times to generate a resource index
81-
func GetNextCommitStatusIndex(repoID int64, sha string) (int64, error) {
82-
for i := 0; i < db.MaxDupIndexAttempts; i++ {
83-
idx, err := getNextCommitStatusIndex(repoID, sha)
84-
if err == db.ErrResouceOutdated {
85-
continue
86-
}
87-
if err != nil {
88-
return 0, err
89-
}
90-
return idx, nil
68+
func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) {
69+
if setting.Database.UsePostgreSQL {
70+
return postgresGetCommitStatusIndex(ctx, repoID, sha)
9171
}
92-
return 0, db.ErrGetResourceIndexFailed
93-
}
9472

95-
// getNextCommitStatusIndex return the next index
96-
func getNextCommitStatusIndex(repoID int64, sha string) (int64, error) {
97-
ctx, commiter, err := db.TxContext()
73+
e := db.GetEngine(ctx)
74+
75+
// try to update the max_index to next value, and acquire the write-lock for the record
76+
res, err := e.Exec("UPDATE `commit_status_index` SET max_index=max_index+1 WHERE repo_id=? AND sha=?", repoID, sha)
9877
if err != nil {
9978
return 0, err
10079
}
101-
defer commiter.Close()
102-
103-
var preIdx int64
104-
_, err = db.GetEngine(ctx).SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id = ? AND sha = ?", repoID, sha).Get(&preIdx)
80+
affected, err := res.RowsAffected()
10581
if err != nil {
10682
return 0, err
10783
}
84+
if affected == 0 {
85+
// this slow path is only for the first time of creating a resource index
86+
_, errIns := e.Exec("INSERT INTO `commit_status_index` (repo_id, sha, max_index) VALUES (?, ?, 0)", repoID, sha)
87+
res, err = e.Exec("UPDATE `commit_status_index` SET max_index=max_index+1 WHERE repo_id=? AND sha=?", repoID, sha)
88+
if err != nil {
89+
return 0, err
90+
}
10891

109-
if err := upsertCommitStatusIndex(ctx, repoID, sha); err != nil {
110-
return 0, err
92+
affected, err = res.RowsAffected()
93+
if err != nil {
94+
return 0, err
95+
}
96+
// if the update still can not update any records, the record must not exist and there must be some errors (insert error)
97+
if affected == 0 {
98+
if errIns == nil {
99+
return 0, errors.New("impossible error when GetNextCommitStatusIndex, insert and update both succeeded but no record is updated")
100+
}
101+
return 0, errIns
102+
}
111103
}
112104

113-
var curIdx int64
114-
has, err := db.GetEngine(ctx).SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id = ? AND sha = ? AND max_index=?", repoID, sha, preIdx+1).Get(&curIdx)
105+
// now, the new index is in database (protected by the transaction and write-lock)
106+
var newIdx int64
107+
has, err := e.SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id=? AND sha=?", repoID, sha).Get(&newIdx)
115108
if err != nil {
116109
return 0, err
117110
}
118111
if !has {
119-
return 0, db.ErrResouceOutdated
120-
}
121-
if err := commiter.Commit(); err != nil {
122-
return 0, err
112+
return 0, errors.New("impossible error when GetNextCommitStatusIndex, upsert succeeded but no record can be selected")
123113
}
124-
return curIdx, nil
114+
return newIdx, nil
125115
}
126116

127117
func (status *CommitStatus) loadAttributes(ctx context.Context) (err error) {
@@ -291,18 +281,18 @@ func NewCommitStatus(opts NewCommitStatusOptions) error {
291281
return fmt.Errorf("NewCommitStatus[%s, %s]: no user specified", repoPath, opts.SHA)
292282
}
293283

294-
// Get the next Status Index
295-
idx, err := GetNextCommitStatusIndex(opts.Repo.ID, opts.SHA)
296-
if err != nil {
297-
return fmt.Errorf("generate commit status index failed: %w", err)
298-
}
299-
300284
ctx, committer, err := db.TxContext()
301285
if err != nil {
302286
return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %w", opts.Repo.ID, opts.Creator.ID, opts.SHA, err)
303287
}
304288
defer committer.Close()
305289

290+
// Get the next Status Index
291+
idx, err := GetNextCommitStatusIndex(ctx, opts.Repo.ID, opts.SHA)
292+
if err != nil {
293+
return fmt.Errorf("generate commit status index failed: %w", err)
294+
}
295+
306296
opts.CommitStatus.Description = strings.TrimSpace(opts.CommitStatus.Description)
307297
opts.CommitStatus.Context = strings.TrimSpace(opts.CommitStatus.Context)
308298
opts.CommitStatus.TargetURL = strings.TrimSpace(opts.CommitStatus.TargetURL)
@@ -316,7 +306,7 @@ func NewCommitStatus(opts NewCommitStatusOptions) error {
316306

317307
// Insert new CommitStatus
318308
if _, err = db.GetEngine(ctx).Insert(opts.CommitStatus); err != nil {
319-
return fmt.Errorf("Insert CommitStatus[%s, %s]: %w", repoPath, opts.SHA, err)
309+
return fmt.Errorf("insert CommitStatus[%s, %s]: %w", repoPath, opts.SHA, err)
320310
}
321311

322312
return committer.Commit()

tests/integration/repo_commits_test.go

+31
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
package integration
66

77
import (
8+
"fmt"
89
"net/http"
910
"net/http/httptest"
1011
"path"
12+
"sync"
1113
"testing"
1214

1315
"code.gitea.io/gitea/modules/json"
@@ -115,3 +117,32 @@ func TestRepoCommitsWithStatusFailure(t *testing.T) {
115117
func TestRepoCommitsWithStatusWarning(t *testing.T) {
116118
doTestRepoCommitWithStatus(t, "warning", "gitea-exclamation", "yellow")
117119
}
120+
121+
func TestRepoCommitsStatusParallel(t *testing.T) {
122+
defer tests.PrepareTestEnv(t)()
123+
124+
session := loginUser(t, "user2")
125+
126+
// Request repository commits page
127+
req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
128+
resp := session.MakeRequest(t, req, http.StatusOK)
129+
130+
doc := NewHTMLParser(t, resp.Body)
131+
// Get first commit URL
132+
commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href")
133+
assert.True(t, exists)
134+
assert.NotEmpty(t, commitURL)
135+
136+
var wg sync.WaitGroup
137+
for i := 0; i < 10; i++ {
138+
wg.Add(1)
139+
go func(parentT *testing.T, i int) {
140+
parentT.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) {
141+
runBody := doAPICreateCommitStatus(NewAPITestContext(t, "user2", "repo1"), path.Base(commitURL), api.CommitStatusState("pending"))
142+
runBody(t)
143+
wg.Done()
144+
})
145+
}(t, i)
146+
}
147+
wg.Wait()
148+
}

0 commit comments

Comments
 (0)