307 lines
9.5 KiB
Go
307 lines
9.5 KiB
Go
package workbench
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
|||
|
|
"runtime/debug"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|||
|
|
"github.com/google/uuid"
|
|||
|
|
"gorm.io/gorm"
|
|||
|
|
"gorm.io/gorm/clause"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func requireIdle(tx *gorm.DB, tenant uint64, id string) error {
|
|||
|
|
var count int64
|
|||
|
|
e := tx.Model(&Job{}).Where("tenant_id = ? AND resource_id = ? AND status IN ?", tenant, id, []string{"queued", "running"}).Count(&count).Error
|
|||
|
|
if e != nil {
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
if count > 0 {
|
|||
|
|
return ErrConflict
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
func allowedAction(kind, action string) bool {
|
|||
|
|
actions := map[string][]string{"training-maps": {"generate", "revise", "generate-day", "confirm-day", "confirm"}, "content-tasks": {"decompose", "import-image", "generate", "revise", "accept", "skip", "review", "review-settings", "compose"}}
|
|||
|
|
for _, a := range actions[kind] {
|
|||
|
|
if a == action {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
func (s *Service) submit(ctx context.Context, kind, id, action string, request ActionRequest) (*Job, error) {
|
|||
|
|
if !allowedAction(kind, action) {
|
|||
|
|
return nil, ErrInvalid
|
|||
|
|
}
|
|||
|
|
if !accesspolicy.Allowed(ctx, accesspolicy.WorkbenchActionPermission(kind, action)) {
|
|||
|
|
return nil, ErrForbidden
|
|||
|
|
}
|
|||
|
|
if len([]rune(request.Advice)) > 6000 || len([]rune(request.Quote)) > 6000 {
|
|||
|
|
return nil, ErrInvalid
|
|||
|
|
}
|
|||
|
|
if action == "revise" && request.Advice == "" {
|
|||
|
|
return nil, fmt.Errorf("%w: 请填写修改建议", ErrInvalid)
|
|||
|
|
}
|
|||
|
|
if action == "review-settings" {
|
|||
|
|
if request.QualitySettings == nil {
|
|||
|
|
return nil, fmt.Errorf("%w: 请填写质检设置", ErrInvalid)
|
|||
|
|
}
|
|||
|
|
if err := request.QualitySettings.validate(); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
t, u, _, e := identity(ctx)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
j := &Job{ID: uuid.NewString(), TenantID: t, OwnerID: u, ResourceID: id, Kind: kind, Action: action, Status: "queued", Stage: "等待执行", Request: asJSON(request), CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|||
|
|
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|||
|
|
current, err := lockResource(ctx, tx, kind, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
// Generation is billed to its owner; administrator inspection and manual
|
|||
|
|
// edits must not silently enqueue billable work for somebody else.
|
|||
|
|
if current.OwnerID != u {
|
|||
|
|
return ErrForbidden
|
|||
|
|
}
|
|||
|
|
if current.Version != request.Version {
|
|||
|
|
return ErrConflict
|
|||
|
|
}
|
|||
|
|
if err = requireActionExecutable(current, action); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if kind == "content-tasks" {
|
|||
|
|
if err = s.validateTaskAction(ctx, current, action); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
} else if err = s.validate(ctx, current); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if err = requireIdle(tx, t, id); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
return tx.Create(j).Error
|
|||
|
|
})
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
_ = s.event(ctx, id, action, "提交任务:"+actionName(action), "info")
|
|||
|
|
return j, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Start uses database leases so multiple service instances cannot claim the same
|
|||
|
|
// job. Expired jobs require an explicit retry: a provider request may have succeeded.
|
|||
|
|
func (s *Service) Start(ctx context.Context) {
|
|||
|
|
go func() {
|
|||
|
|
ticker := time.NewTicker(2 * time.Second)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
for {
|
|||
|
|
select {
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return
|
|||
|
|
case <-ticker.C:
|
|||
|
|
s.tick(ctx)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}()
|
|||
|
|
}
|
|||
|
|
func (s *Service) tick(ctx context.Context) {
|
|||
|
|
if err := s.releaseExpiredEvidence(ctx); err != nil {
|
|||
|
|
logJobFailure(ctx, nil, "evidence_recovery", err)
|
|||
|
|
}
|
|||
|
|
if err := s.releaseExpiredBalances(ctx); err != nil {
|
|||
|
|
logJobFailure(ctx, nil, "balance_recovery", err)
|
|||
|
|
}
|
|||
|
|
now := time.Now()
|
|||
|
|
if err := s.recoverExpiredJobs(ctx, now); err != nil {
|
|||
|
|
logJobFailure(ctx, nil, "lease_recovery", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
j, err := s.claim(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
logJobFailure(ctx, nil, "claim", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if j == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
s.run(ctx, j)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) claim(ctx context.Context) (*Job, error) {
|
|||
|
|
var j Job
|
|||
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|||
|
|
q := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status = ?", "queued").Order("created_at ASC, id ASC").Limit(1).Find(&j)
|
|||
|
|
if q.Error != nil || q.RowsAffected == 0 {
|
|||
|
|
return q.Error
|
|||
|
|
}
|
|||
|
|
now := time.Now()
|
|||
|
|
until := now.Add(10 * time.Minute)
|
|||
|
|
j.Status = "running"
|
|||
|
|
j.Progress = 5
|
|||
|
|
j.Stage = "检查任务配置"
|
|||
|
|
j.LeaseToken = uuid.NewString()
|
|||
|
|
j.LeaseUntil = &until
|
|||
|
|
q = tx.Model(&Job{}).Where("id = ? AND status = ?", j.ID, "queued").Updates(map[string]any{"status": j.Status, "progress": j.Progress, "stage": j.Stage, "lease_token": j.LeaseToken, "lease_until": until, "updated_at": now})
|
|||
|
|
if q.Error != nil {
|
|||
|
|
return q.Error
|
|||
|
|
}
|
|||
|
|
if q.RowsAffected != 1 {
|
|||
|
|
return ErrConflict
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if j.ID == "" {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
return &j, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type jobLease struct{ ID, Token string }
|
|||
|
|
type leaseContextKey struct{}
|
|||
|
|
|
|||
|
|
func (s *Service) progress(ctx context.Context, id string, percent int, stage string) error {
|
|||
|
|
lease, ok := ctx.Value(leaseContextKey{}).(jobLease)
|
|||
|
|
if !ok || lease.ID != id {
|
|||
|
|
return errors.New("missing job lease")
|
|||
|
|
}
|
|||
|
|
q := s.db.WithContext(ctx).Model(&Job{}).Where("id = ? AND lease_token = ? AND status = ? AND lease_until > ?", id, lease.Token, "running", time.Now()).Updates(map[string]any{"progress": percent, "stage": stage, "updated_at": time.Now()})
|
|||
|
|
if q.Error != nil {
|
|||
|
|
return q.Error
|
|||
|
|
}
|
|||
|
|
if q.RowsAffected != 1 {
|
|||
|
|
return ErrConflict
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
func (s *Service) run(parent context.Context, j *Job) {
|
|||
|
|
ctx, cancel := context.WithTimeout(parent, 8*time.Minute)
|
|||
|
|
defer cancel()
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, j.TenantID)
|
|||
|
|
ctx = context.WithValue(ctx, types.UserIDContextKey, j.OwnerID)
|
|||
|
|
ctx = context.WithValue(ctx, leaseContextKey{}, jobLease{j.ID, j.LeaseToken})
|
|||
|
|
ctx = types.WithBackgroundTask(ctx)
|
|||
|
|
go s.watchJobLease(ctx, cancel, j)
|
|||
|
|
var runErr error
|
|||
|
|
defer func() {
|
|||
|
|
if panicValue := recover(); panicValue != nil {
|
|||
|
|
runErr = errors.New("后台任务发生异常,请重试")
|
|||
|
|
logger.GetLogger(ctx).WithFields(logger.Fields{"job_id": j.ID, "panic_type": fmt.Sprintf("%T", panicValue), "stack": string(debug.Stack())}).Error("工作台任务发生异常")
|
|||
|
|
}
|
|||
|
|
finishCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|||
|
|
defer stop()
|
|||
|
|
status, stage, message := "succeeded", "已完成", ""
|
|||
|
|
updates := map[string]any{"status": status, "stage": stage, "progress": 100, "error": message, "updated_at": time.Now()}
|
|||
|
|
if runErr != nil {
|
|||
|
|
if err := s.releaseJobEvidence(finishCtx, j); err != nil {
|
|||
|
|
logJobFailure(finishCtx, j, "evidence_release", err)
|
|||
|
|
}
|
|||
|
|
status = "failed"
|
|||
|
|
stage = "执行失败"
|
|||
|
|
message = publicJobError(runErr)
|
|||
|
|
logJobFailure(ctx, j, "execute", runErr)
|
|||
|
|
updates["status"], updates["stage"], updates["error"] = status, stage, message
|
|||
|
|
// Preserve the latest persisted stage progress instead of resetting to 5%.
|
|||
|
|
delete(updates, "progress")
|
|||
|
|
}
|
|||
|
|
q := s.db.WithContext(finishCtx).Model(&Job{}).Where("id = ? AND lease_token = ? AND status = ? AND lease_until > ?", j.ID, j.LeaseToken, "running", time.Now()).Updates(updates)
|
|||
|
|
if q.Error != nil {
|
|||
|
|
logJobFailure(finishCtx, j, "finalize", q.Error)
|
|||
|
|
s.recordJobFailure(finishCtx, j, "finalize", q.Error)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if q.RowsAffected != 1 {
|
|||
|
|
var current Job
|
|||
|
|
if err := s.db.WithContext(finishCtx).Where("id=?", j.ID).Take(¤t).Error; err == nil && (current.Status == "paused" || current.Status == "cancelled") {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logJobFailure(finishCtx, j, "finalize_lease", ErrConflict)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
level := "info"
|
|||
|
|
if runErr != nil {
|
|||
|
|
level = "error"
|
|||
|
|
s.recordJobFailure(finishCtx, j, "execute", runErr)
|
|||
|
|
}
|
|||
|
|
if err := s.event(finishCtx, j.ResourceID, j.Action, stage+":"+message, level); err != nil {
|
|||
|
|
logJobFailure(finishCtx, j, "event", err)
|
|||
|
|
}
|
|||
|
|
}()
|
|||
|
|
// Re-read membership on execution; queued work does not preserve revoked roles.
|
|||
|
|
var member types.TenantMember
|
|||
|
|
runErr = s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", j.OwnerID, j.TenantID, types.TenantMemberStatusActive).First(&member).Error
|
|||
|
|
if errors.Is(runErr, gorm.ErrRecordNotFound) || (runErr == nil && !member.Role.HasPermission(types.TenantRoleContributor)) {
|
|||
|
|
runErr = ErrForbidden
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if runErr != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = context.WithValue(ctx, types.TenantRoleContextKey, member.Role)
|
|||
|
|
ctx, runErr = s.restoreJobTenant(ctx, j.TenantID)
|
|||
|
|
if runErr != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if s.accessSnapshot != nil {
|
|||
|
|
var snapshot *accesspolicy.Snapshot
|
|||
|
|
snapshot, runErr = s.accessSnapshot(ctx)
|
|||
|
|
if runErr != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ctx = accesspolicy.WithSnapshot(ctx, snapshot)
|
|||
|
|
if !accesspolicy.Allowed(ctx, accesspolicy.WorkbenchActionPermission(j.Kind, j.Action)) {
|
|||
|
|
runErr = ErrForbidden
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
r, e := s.get(ctx, j.Kind, j.ResourceID)
|
|||
|
|
if e != nil {
|
|||
|
|
runErr = e
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
request, e := decode[ActionRequest](j.Request)
|
|||
|
|
if e != nil {
|
|||
|
|
runErr = e
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if r.Version != request.Version {
|
|||
|
|
runErr = ErrConflict
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if runErr = requireActionExecutable(r, j.Action); runErr != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if r.Kind == "content-tasks" {
|
|||
|
|
if runErr = s.validateTaskAction(ctx, r, j.Action); runErr != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
} else if runErr = s.validate(ctx, r); runErr != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
switch r.Kind {
|
|||
|
|
case "training-maps":
|
|||
|
|
runErr = s.executeMap(ctx, j, r, request)
|
|||
|
|
case "content-tasks":
|
|||
|
|
runErr = s.executeTask(ctx, j, r, request)
|
|||
|
|
default:
|
|||
|
|
runErr = ErrInvalid
|
|||
|
|
}
|
|||
|
|
if runErr == nil {
|
|||
|
|
runErr = s.save(ctx, r, request.Version)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
func publicJobError(e error) string {
|
|||
|
|
_, message := jobErrorDetails(e)
|
|||
|
|
return message
|
|||
|
|
}
|