fix: serialize concurrent demo seeding
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
const demoTransactionAttempts = 3
|
||||
|
||||
type DemoOptions struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
@@ -35,43 +39,68 @@ func Demo(database *gorm.DB, options DemoOptions) (DemoResult, error) {
|
||||
}
|
||||
|
||||
var result DemoResult
|
||||
err := database.Transaction(func(tx *gorm.DB) error {
|
||||
user, err := upsertUser(tx, email, displayName, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.User = user
|
||||
var err error
|
||||
for attempt := 0; attempt < demoTransactionAttempts; attempt++ {
|
||||
result = DemoResult{}
|
||||
err = database.Transaction(func(tx *gorm.DB) error {
|
||||
user, err := upsertAndLockUser(tx, email, displayName, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.User = user
|
||||
|
||||
projects, err := seedProjects(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
projects, err := seedProjects(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Projects = projects
|
||||
return nil
|
||||
})
|
||||
if err == nil || !isRetryableDemoTransactionError(err) || attempt == demoTransactionAttempts-1 {
|
||||
return result, err
|
||||
}
|
||||
result.Projects = projects
|
||||
return nil
|
||||
})
|
||||
// 重试发生在事务完全回滚之后,避免持锁等待自身的新事务。
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func upsertUser(tx *gorm.DB, email string, displayName string, password string) (models.SenlinAgentUser, error) {
|
||||
var user models.SenlinAgentUser
|
||||
err := tx.Where("email = ?", email).First(&user).Error
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return user, err
|
||||
}
|
||||
func upsertAndLockUser(tx *gorm.DB, email string, displayName string, password string) (models.SenlinAgentUser, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return user, err
|
||||
return models.SenlinAgentUser{}, err
|
||||
}
|
||||
user = models.SenlinAgentUser{
|
||||
candidate := models.SenlinAgentUser{
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
PasswordHash: string(hash),
|
||||
Role: "user",
|
||||
}
|
||||
return user, tx.Create(&user).Error
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "email"}},
|
||||
DoNothing: true,
|
||||
}).Create(&candidate).Error; err != nil {
|
||||
return models.SenlinAgentUser{}, err
|
||||
}
|
||||
|
||||
// PostgreSQL 会先等待并发的同 email INSERT 提交,再在 READ COMMITTED 的下一条语句中看见该用户。
|
||||
// 锁定 owner 后,项目、标签和子对象的 find-or-create 在不同进程间也会按同一用户串行执行。
|
||||
var user models.SenlinAgentUser
|
||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&user).Error
|
||||
return user, err
|
||||
}
|
||||
|
||||
func isRetryableDemoTransactionError(err error) bool {
|
||||
var sqlState interface{ SQLState() string }
|
||||
if !errors.As(err, &sqlState) {
|
||||
return false
|
||||
}
|
||||
switch sqlState.SQLState() {
|
||||
case "40001", "40P01":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error) {
|
||||
|
||||
Reference in New Issue
Block a user