feat: harden multi-user download concurrency

This commit is contained in:
OpenClaw Agent
2026-09-18 09:06:59 +08:00
parent 0735cb2e09
commit b116303abe
11 changed files with 1003 additions and 202 deletions
+6 -68
View File
@@ -2,9 +2,6 @@ package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -23,6 +20,8 @@ type MediaAsset struct {
Size int64 `json:"size"`
Status string `gorm:"index;size:20" json:"status"`
OwnerTaskID uint `gorm:"index" json:"ownerTaskId,omitempty"`
LeaseUntil time.Time `gorm:"index" json:"-"`
Generation uint64 `json:"generation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -58,70 +57,9 @@ func (a *App) ensureAsset(t *Task) (*MediaAsset, error) {
return &asset, nil
}
// acquireAsset serializes physical downloads for the same source/rid/quality.
// A waiting task yields its worker briefly instead of creating duplicate files.
func (a *App) acquireAsset(t *Task) (*MediaAsset, bool, error) {
for i := 0; i < 120; i++ {
asset, err := a.ensureAsset(t)
if err != nil {
return nil, false, err
}
if asset.Status == "done" && asset.LocalPath != "" {
if fi, statErr := os.Stat(asset.LocalPath); statErr == nil && fi.Size() > 0 {
t.LocalPath, t.Format, t.ActualQuality = asset.LocalPath, asset.Format, asset.ActualQuality
t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = fi.Size(), fi.Size(), 100, "done"
if err := a.db.Save(t).Error; err != nil {
return nil, false, err
}
return asset, true, nil
}
}
claimed := false
err = a.db.Transaction(func(tx *gorm.DB) error {
var current MediaAsset
if err := tx.First(&current, asset.ID).Error; err != nil {
return err
}
if current.Status == "downloading" && current.OwnerTaskID != t.ID {
if current.UpdatedAt.After(time.Now().Add(-10 * time.Minute)) {
return nil
}
}
current.Status, current.OwnerTaskID = "downloading", t.ID
if current.LocalPath == "" {
current.LocalPath = filepath.Join(a.cfg.DownloadDir, safe(t.Artist)+" - "+safe(t.Title)+"."+t.Format)
}
if err := tx.Save(&current).Error; err != nil {
return err
}
*asset = current
claimed = true
return nil
})
if err != nil {
return nil, false, err
}
if claimed {
t.LocalPath = asset.LocalPath
return asset, false, nil
}
time.Sleep(500 * time.Millisecond)
}
return nil, false, fmt.Errorf("timed out waiting for shared media asset")
}
// acquireAsset is the compatibility entry point.
func (a *App) acquireAsset(t *Task) (*MediaAsset, bool, error) { return a.claimAsset(t) }
func (a *App) markAssetDone(t *Task) {
if t.AssetID == 0 {
return
}
_ = a.db.Model(&MediaAsset{}).Where("id = ? AND owner_task_id = ?", t.AssetID, t.ID).Updates(map[string]any{
"status": "done", "owner_task_id": 0, "local_path": t.LocalPath, "format": t.Format, "actual_quality": t.ActualQuality, "size": t.DownloadedBytes,
}).Error
}
func (a *App) markAssetDone(t *Task) { _ = a.finishAsset(t) }
func (a *App) releaseAsset(t *Task) {
if t.AssetID == 0 {
return
}
_ = a.db.Model(&MediaAsset{}).Where("id = ? AND owner_task_id = ?", t.AssetID, t.ID).Updates(map[string]any{"status": "error", "owner_task_id": 0}).Error
}
func (a *App) releaseAsset(t *Task) { a.invalidateAsset(t) }