feat: harden multi-user download concurrency
This commit is contained in:
+60
-33
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -16,50 +17,76 @@ var (
|
||||
|
||||
// reserveCredit places one credit on hold for a task. The hold is released on
|
||||
// failure or converted into a debit after a successful new download.
|
||||
func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) {
|
||||
func reserveCreditTx(tx *gorm.DB, userID, taskID uint, amount int64) (*CreditReservation, error) {
|
||||
if amount <= 0 {
|
||||
return nil, fmt.Errorf("invalid credit amount")
|
||||
}
|
||||
var reservation CreditReservation
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var account CreditAccount
|
||||
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
account = CreditAccount{UserID: userID}
|
||||
if err := tx.Create(&account).Error; err != nil {
|
||||
return err
|
||||
var account CreditAccount
|
||||
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
account = CreditAccount{UserID: userID}
|
||||
if err := tx.Create(&account).Error; err != nil {
|
||||
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var existing CreditReservation
|
||||
if err := tx.Where("task_id = ? AND status = ?", taskID, "reserved").Order("id desc").First(&existing).Error; err == nil {
|
||||
reservation = existing
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if account.Balance-account.Reserved < amount {
|
||||
return errInsufficientCredits
|
||||
}
|
||||
account.Reserved += amount
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
reservation = CreditReservation{UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved"}
|
||||
if err := tx.Create(&reservation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&CreditTransaction{UserID: userID, Type: "reserve", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(taskID), 10), Reason: "download reservation"}).Error
|
||||
}
|
||||
var existing CreditReservation
|
||||
if err := tx.Where("task_id = ? AND status = ?", taskID, "reserved").
|
||||
Order("id desc").First(&existing).Error; err == nil {
|
||||
return &existing, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if account.Balance-account.Reserved < amount {
|
||||
return nil, errInsufficientCredits
|
||||
}
|
||||
account.Reserved += amount
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reservation := &CreditReservation{
|
||||
UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved",
|
||||
}
|
||||
if err := tx.Create(reservation).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Create(&CreditTransaction{
|
||||
UserID: userID, Type: "reserve", Amount: 0,
|
||||
BalanceAfter: account.Balance, ReferenceType: "task",
|
||||
ReferenceID: strconv.FormatUint(uint64(taskID), 10),
|
||||
Reason: "download reservation",
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
func isSQLiteBusy(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "sqlite_busy") || strings.Contains(msg, "database is locked")
|
||||
}
|
||||
|
||||
func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) {
|
||||
var reservation *CreditReservation
|
||||
err := a.transactionRetry(func(tx *gorm.DB) error {
|
||||
var txErr error
|
||||
reservation, txErr = reserveCreditTx(tx, userID, taskID, amount)
|
||||
return txErr
|
||||
})
|
||||
return &reservation, err
|
||||
return reservation, err
|
||||
}
|
||||
|
||||
func (a *App) commitCredit(task *Task) error {
|
||||
if task.CreditReservationID == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
return a.transactionRetry(func(tx *gorm.DB) error {
|
||||
var r CreditReservation
|
||||
if err := tx.First(&r, task.CreditReservationID).Error; err != nil {
|
||||
return err
|
||||
@@ -97,7 +124,7 @@ func (a *App) refundCredit(task *Task) error {
|
||||
if task.CreditReservationID == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
return a.transactionRetry(func(tx *gorm.DB) error {
|
||||
var r CreditReservation
|
||||
if err := tx.First(&r, task.CreditReservationID).Error; err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user