fix: serialize concurrent demo seeding

This commit is contained in:
2026-07-21 20:21:49 +08:00
parent d2ef10763c
commit d9440b5334
3 changed files with 311 additions and 24 deletions

View File

@@ -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) {