mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
优化任务管理
This commit is contained in:
@@ -7,13 +7,17 @@ package task
|
||||
|
||||
// 任务管理相关错误消息
|
||||
const (
|
||||
InvalidTaskType = "无效的任务类型"
|
||||
InvalidTimeRange = "无效的时间范围"
|
||||
TaskDispatchFailed = "任务下发失败"
|
||||
UserIDRequired = "用户ID必填"
|
||||
TaskNotFound = "任务执行记录不存在"
|
||||
TaskNotRetryable = "该任务不支持重试"
|
||||
TaskNotFailed = "只有失败的任务才能重试"
|
||||
TaskMaxRetryExceeded = "已达到最大重试次数"
|
||||
TaskRetryFailed = "任务重试失败"
|
||||
InvalidTaskType = "无效的任务类型"
|
||||
InvalidTimeRange = "无效的时间范围"
|
||||
TaskDispatchFailed = "任务下发失败"
|
||||
UserIDRequired = "用户ID必填"
|
||||
TaskNotFound = "任务执行记录不存在"
|
||||
TaskNotRetryable = "该任务不支持重试"
|
||||
TaskNotFailed = "只有失败的任务才能重试"
|
||||
TaskMaxRetryExceeded = "已达到最大重试次数"
|
||||
TaskRetryFailed = "任务重试失败"
|
||||
InvalidCronExpression = "无效的 Cron 表达式"
|
||||
ScheduleNotFound = "定时任务不存在"
|
||||
ScheduleSaveFailed = "保存定时任务失败"
|
||||
ScheduleDeleteFailed = "删除定时任务失败"
|
||||
)
|
||||
|
||||
@@ -12,11 +12,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
"github.com/Rain-kl/Wavelet/internal/logger"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
|
||||
"github.com/Rain-kl/Wavelet/internal/task/scheduler"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -194,3 +197,218 @@ func RetryTask(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(newTaskID))
|
||||
}
|
||||
|
||||
// ListSchedules 获取定时任务列表
|
||||
// @Summary 获取定时任务列表
|
||||
// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=[]model.Schedule} "定时任务列表"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Router /api/v1/admin/tasks/schedules [get]
|
||||
func ListSchedules(c *gin.Context) {
|
||||
schedules, err := model.ListSchedules(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, util.OK(schedules))
|
||||
}
|
||||
|
||||
// CreateScheduleRequest 创建定时任务请求
|
||||
type CreateScheduleRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
TaskType string `json:"task_type" binding:"required"`
|
||||
Cron string `json:"cron" binding:"required"`
|
||||
Payload string `json:"payload"`
|
||||
IsActive *bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateSchedule 创建定时任务
|
||||
// @Summary 创建定时任务
|
||||
// @Description 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body CreateScheduleRequest true "创建定时任务请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=model.Schedule} "创建成功的定时任务信息"
|
||||
// @Failure 400 {object} util.ResponseAny "Cron 表达式无效、异步任务类型不存在或参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "保存定时任务失败"
|
||||
// @Router /api/v1/admin/tasks/schedules [post]
|
||||
func CreateSchedule(c *gin.Context) {
|
||||
var req CreateScheduleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 Cron 表达式
|
||||
if _, err := cron.ParseStandard(req.Cron); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(InvalidCronExpression))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验关联的异步任务类型
|
||||
meta := task.GetTaskMeta(req.TaskType)
|
||||
if meta == nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(InvalidTaskType))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验并规范化 Payload
|
||||
var payloadBytes []byte
|
||||
if strings.TrimSpace(req.Payload) != "" {
|
||||
payloadBytes = []byte(req.Payload)
|
||||
}
|
||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
schedule := &model.Schedule{
|
||||
Name: req.Name,
|
||||
TaskType: req.TaskType,
|
||||
Cron: req.Cron,
|
||||
Payload: string(validated),
|
||||
IsActive: *req.IsActive,
|
||||
}
|
||||
|
||||
if err := model.CreateSchedule(c.Request.Context(), schedule); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", ScheduleSaveFailed, err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(schedule))
|
||||
}
|
||||
|
||||
// UpdateScheduleRequest 修改定时任务请求
|
||||
type UpdateScheduleRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
TaskType string `json:"task_type" binding:"required"`
|
||||
Cron string `json:"cron" binding:"required"`
|
||||
Payload string `json:"payload"`
|
||||
IsActive *bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateSchedule 修改定时任务
|
||||
// @Summary 修改定时任务
|
||||
// @Description 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "定时任务 ID"
|
||||
// @Param request body UpdateScheduleRequest true "修改定时任务请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=model.Schedule} "修改后的定时任务信息"
|
||||
// @Failure 400 {object} util.ResponseAny "Cron 表达式无效、参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 404 {object} util.ResponseAny "定时任务不存在"
|
||||
// @Failure 500 {object} util.ResponseAny "修改定时任务失败"
|
||||
// @Router /api/v1/admin/tasks/schedules/{id} [put]
|
||||
func UpdateSchedule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err("无效的定时任务ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateScheduleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查定时任务是否存在
|
||||
schedule, err := model.GetScheduleByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, util.Err(ScheduleNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 Cron 表达式
|
||||
if _, err := cron.ParseStandard(req.Cron); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(InvalidCronExpression))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验关联的异步任务类型
|
||||
meta := task.GetTaskMeta(req.TaskType)
|
||||
if meta == nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(InvalidTaskType))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验并规范化 Payload
|
||||
var payloadBytes []byte
|
||||
if strings.TrimSpace(req.Payload) != "" {
|
||||
payloadBytes = []byte(req.Payload)
|
||||
}
|
||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
schedule.Name = req.Name
|
||||
schedule.TaskType = req.TaskType
|
||||
schedule.Cron = req.Cron
|
||||
schedule.Payload = string(validated)
|
||||
schedule.IsActive = *req.IsActive
|
||||
|
||||
if err := model.UpdateSchedule(c.Request.Context(), schedule); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", ScheduleSaveFailed, err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(schedule))
|
||||
}
|
||||
|
||||
// DeleteSchedule 删除定时任务
|
||||
// @Summary 删除定时任务
|
||||
// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "定时任务 ID"
|
||||
// @Success 200 {object} util.ResponseAny{data=string} "删除结果"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "删除定时任务失败"
|
||||
// @Router /api/v1/admin/tasks/schedules/{id} [delete]
|
||||
func DeleteSchedule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err("无效的定时任务ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeleteSchedule(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", ScheduleDeleteFailed, err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
@@ -246,14 +246,6 @@ func applyEnvOverrides(c *configModel) {
|
||||
{Name: "default", Priority: defaultQueuePriority},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scheduler ───
|
||||
c.Scheduler.CleanupUnusedUploadsTaskCron = envStr(
|
||||
"SCHEDULER_CLEANUP_CRON", c.Scheduler.CleanupUnusedUploadsTaskCron,
|
||||
)
|
||||
if c.Scheduler.CleanupUnusedUploadsTaskCron == "" {
|
||||
c.Scheduler.CleanupUnusedUploadsTaskCron = "@daily"
|
||||
}
|
||||
}
|
||||
|
||||
// printConfig 打印配置内容
|
||||
|
||||
@@ -120,7 +120,6 @@ type logConfig struct {
|
||||
|
||||
// schedulerConfig 定时任务配置
|
||||
type schedulerConfig struct {
|
||||
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
|
||||
}
|
||||
|
||||
// workerConfig 工作配置
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
task_type VARCHAR(64) NOT NULL,
|
||||
cron VARCHAR(64) NOT NULL,
|
||||
payload TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
|
||||
|
||||
-- Seed initial cleanup task
|
||||
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
|
||||
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS schedules;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
task_type VARCHAR(64) NOT NULL,
|
||||
cron VARCHAR(64) NOT NULL,
|
||||
payload TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
|
||||
|
||||
-- Seed initial cleanup task
|
||||
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
|
||||
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS schedules;
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/db/idgen"
|
||||
)
|
||||
|
||||
// Schedule 定时任务配置表
|
||||
type Schedule struct {
|
||||
ID uint64 `json:"id,string" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
TaskType string `json:"task_type" gorm:"size:64;not null"`
|
||||
Cron string `json:"cron" gorm:"size:64;not null"`
|
||||
Payload string `json:"payload" gorm:"type:text"`
|
||||
IsActive bool `json:"is_active" gorm:"not null;default:true"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (Schedule) TableName() string {
|
||||
return "schedules"
|
||||
}
|
||||
|
||||
// CreateSchedule 创建定时任务
|
||||
func CreateSchedule(ctx context.Context, schedule *Schedule) error {
|
||||
schedule.ID = idgen.NextUint64ID()
|
||||
return db.DB(ctx).Create(schedule).Error
|
||||
}
|
||||
|
||||
// UpdateSchedule 更新定时任务
|
||||
func UpdateSchedule(ctx context.Context, schedule *Schedule) error {
|
||||
return db.DB(ctx).Save(schedule).Error
|
||||
}
|
||||
|
||||
// DeleteSchedule 删除定时任务
|
||||
func DeleteSchedule(ctx context.Context, id uint64) error {
|
||||
return db.DB(ctx).Delete(&Schedule{}, id).Error
|
||||
}
|
||||
|
||||
// GetScheduleByID 根据 ID 获取定时任务
|
||||
func GetScheduleByID(ctx context.Context, id uint64) (*Schedule, error) {
|
||||
var schedule Schedule
|
||||
if err := db.DB(ctx).Where("id = ?", id).First(&schedule).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &schedule, nil
|
||||
}
|
||||
|
||||
// ListSchedules 获取所有定时任务
|
||||
func ListSchedules(ctx context.Context) ([]Schedule, error) {
|
||||
var schedules []Schedule
|
||||
if err := db.DB(ctx).Order("id DESC").Find(&schedules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// ListActiveSchedules 获取所有启用的定时任务
|
||||
func ListActiveSchedules(ctx context.Context) ([]Schedule, error) {
|
||||
var schedules []Schedule
|
||||
if err := db.DB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schedules, nil
|
||||
}
|
||||
@@ -244,6 +244,12 @@ func registerRoutes(r *gin.Engine) {
|
||||
adminRouter.GET("/tasks/executions/:id", admin_task.GetTaskExecution)
|
||||
adminRouter.POST("/tasks/executions/:id/retry", admin_task.RetryTask)
|
||||
|
||||
// Task schedules
|
||||
adminRouter.GET("/tasks/schedules", admin_task.ListSchedules)
|
||||
adminRouter.POST("/tasks/schedules", admin_task.CreateSchedule)
|
||||
adminRouter.PUT("/tasks/schedules/:id", admin_task.UpdateSchedule)
|
||||
adminRouter.DELETE("/tasks/schedules/:id", admin_task.DeleteSchedule)
|
||||
|
||||
// Users
|
||||
adminRouter.GET("/users", admin_user.ListUsers)
|
||||
adminRouter.POST("/users", admin_user.CreateUser)
|
||||
|
||||
@@ -111,3 +111,13 @@ func GetTaskMeta(taskType string) *TaskMeta {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTaskMetaByAsynqTask 根据 Asynq 任务名称获取元数据
|
||||
func GetTaskMetaByAsynqTask(asynqTask string) *TaskMeta {
|
||||
for _, t := range DispatchableTasks {
|
||||
if t.AsynqTask == asynqTask {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+62
-36
@@ -218,25 +218,15 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 从数据库加载执行记录
|
||||
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 查询执行记录失败 taskID=%s: %v", taskID, err)
|
||||
// 执行记录不存在,仍然执行任务但不记录状态
|
||||
_, execErr := handler.Execute(ctx, t.Payload())
|
||||
if execErr != nil {
|
||||
span.SetStatus(codes.Error, execErr.Error())
|
||||
return execErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新状态为 running
|
||||
// 加载或动态创建执行记录
|
||||
now := time.Now()
|
||||
execution.Status = model.TaskExecutionStatusRunning
|
||||
execution.StartedAt = &now
|
||||
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, err)
|
||||
execution, err := getOrCreateTaskExecution(ctx, taskID, t, now)
|
||||
if err == nil && execution != nil && execution.TriggeredBy != "schedule" {
|
||||
execution.Status = model.TaskExecutionStatusRunning
|
||||
execution.StartedAt = &now
|
||||
if updateErr := model.UpdateTaskExecution(ctx, execution); updateErr != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始计时
|
||||
@@ -245,26 +235,69 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
// 执行业务逻辑
|
||||
result, execErr := handler.Execute(ctx, t.Payload())
|
||||
|
||||
// 计算耗时
|
||||
// 计算耗时并归档记录
|
||||
duration := time.Since(start)
|
||||
finishTime := time.Now()
|
||||
|
||||
completeTaskExecution(ctx, execution, t, duration, finishTime, result, execErr, span)
|
||||
|
||||
if execution == nil && execErr != nil {
|
||||
span.SetStatus(codes.Error, execErr.Error())
|
||||
return execErr
|
||||
}
|
||||
|
||||
return execErr
|
||||
}
|
||||
|
||||
// getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录
|
||||
func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, now time.Time) (*model.TaskExecution, error) {
|
||||
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
if err == nil {
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
meta := GetTaskMetaByAsynqTask(t.Type())
|
||||
if meta == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
execution = &model.TaskExecution{
|
||||
TaskID: taskID,
|
||||
TaskType: meta.AsynqTask,
|
||||
TaskName: meta.Name,
|
||||
Status: model.TaskExecutionStatusRunning,
|
||||
Retryable: meta.Retryable,
|
||||
MaxRetry: meta.MaxRetry,
|
||||
RetryCount: 0,
|
||||
Payload: string(t.Payload()),
|
||||
TriggeredBy: "schedule",
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
if createErr := model.CreateTaskExecution(ctx, execution); createErr != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr)
|
||||
return nil, createErr
|
||||
}
|
||||
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// completeTaskExecution 完成并更新任务执行记录的状态和执行结果
|
||||
func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, finishTime time.Time, result *TaskResult, execErr error, span trace.Span) {
|
||||
if execution == nil {
|
||||
return
|
||||
}
|
||||
|
||||
execution.Duration = duration.Milliseconds()
|
||||
execution.FinishedAt = &finishTime
|
||||
|
||||
if execErr != nil {
|
||||
// 执行失败
|
||||
execution.Status = model.TaskExecutionStatusFailed
|
||||
execution.ErrorMessage = execErr.Error()
|
||||
|
||||
logger.ErrorF(ctx,
|
||||
"[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v",
|
||||
t.Type(), taskID, duration.Milliseconds(), execErr,
|
||||
)
|
||||
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr)
|
||||
span.SetStatus(codes.Error, execErr.Error())
|
||||
span.RecordError(execErr)
|
||||
} else {
|
||||
// 执行成功
|
||||
execution.Status = model.TaskExecutionStatusSucceeded
|
||||
if result != nil {
|
||||
execution.Result = result.Message
|
||||
@@ -272,19 +305,12 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoF(ctx,
|
||||
"[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms",
|
||||
t.Type(), taskID, duration.Milliseconds(),
|
||||
)
|
||||
logger.InfoF(ctx, "[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds())
|
||||
}
|
||||
|
||||
// 更新执行记录
|
||||
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", taskID, err)
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err)
|
||||
}
|
||||
|
||||
return execErr
|
||||
}
|
||||
|
||||
// generateTaskID 生成任务 ID
|
||||
|
||||
@@ -1,67 +1,127 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/logger"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
const (
|
||||
cleanupDedupWindow = 23 * time.Hour // 清理任务去重窗口
|
||||
cleanupMaxRetry = 3 // 清理任务最大重试次数
|
||||
)
|
||||
|
||||
var (
|
||||
scheduler *asynq.Scheduler
|
||||
schedulerOnce sync.Once
|
||||
activeScheduler *asynq.Scheduler
|
||||
schedulerMutex sync.Mutex
|
||||
quitChan chan struct{}
|
||||
schedulerOnce sync.Once
|
||||
)
|
||||
|
||||
func init() {
|
||||
// AsynqClient 已在 task 包中初始化
|
||||
}
|
||||
|
||||
// GetAsynqClient 获取全局 AsynqClient
|
||||
func GetAsynqClient() *asynq.Client {
|
||||
return task.AsynqClient
|
||||
}
|
||||
|
||||
// StartScheduler 启动调度器
|
||||
// StartScheduler 启动调度器 (该函数阻塞,直到调度器退出)
|
||||
func StartScheduler() error {
|
||||
var err error
|
||||
schedulerOnce.Do(func() {
|
||||
location, locErr := time.LoadLocation("Asia/Shanghai")
|
||||
if locErr != nil {
|
||||
err = fmt.Errorf(errLoadLocationFailed, locErr)
|
||||
return
|
||||
}
|
||||
scheduler = asynq.NewScheduler(
|
||||
task.RedisOpt,
|
||||
&asynq.SchedulerOpts{
|
||||
Location: location,
|
||||
},
|
||||
)
|
||||
quitChan = make(chan struct{})
|
||||
|
||||
// 清理未使用的上传文件任务
|
||||
if _, err = scheduler.Register(
|
||||
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
|
||||
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
|
||||
asynq.Unique(cleanupDedupWindow),
|
||||
asynq.MaxRetry(cleanupMaxRetry),
|
||||
); err != nil {
|
||||
// 初始化并运行首次调度
|
||||
if err = ReloadScheduler(); err != nil {
|
||||
err = fmt.Errorf("initial reload failed: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 启动调度器
|
||||
err = scheduler.Run()
|
||||
// 阻塞等待
|
||||
<-quitChan
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// StopScheduler 停止调度服务并解除 StartScheduler 阻塞
|
||||
func StopScheduler() {
|
||||
schedulerMutex.Lock()
|
||||
defer schedulerMutex.Unlock()
|
||||
|
||||
if activeScheduler != nil {
|
||||
activeScheduler.Shutdown()
|
||||
activeScheduler = nil
|
||||
}
|
||||
|
||||
if quitChan != nil {
|
||||
close(quitChan)
|
||||
quitChan = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadScheduler 重载调度器配置 (线程安全)
|
||||
func ReloadScheduler() error {
|
||||
schedulerMutex.Lock()
|
||||
defer schedulerMutex.Unlock()
|
||||
|
||||
// 1. 如果有运行中的调度器,先关闭它
|
||||
if activeScheduler != nil {
|
||||
activeScheduler.Shutdown()
|
||||
activeScheduler = nil
|
||||
}
|
||||
|
||||
// 2. 从数据库载入启用的定时任务配置
|
||||
schedules, err := model.ListActiveSchedules(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load schedules from db failed: %w", err)
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return fmt.Errorf(errLoadLocationFailed, err)
|
||||
}
|
||||
|
||||
// 3. 实例化新的调度器
|
||||
newScheduler := asynq.NewScheduler(
|
||||
task.RedisOpt,
|
||||
&asynq.SchedulerOpts{
|
||||
Location: location,
|
||||
},
|
||||
)
|
||||
|
||||
// 4. 遍历并注册任务
|
||||
for _, s := range schedules {
|
||||
meta := task.GetTaskMeta(s.TaskType)
|
||||
if meta == nil {
|
||||
continue // 忽略排程配置中无效的任务类型
|
||||
}
|
||||
|
||||
// 构造 Asynq 载荷。定时任务使用对应 Meta 中的 Asynq 标识,同时将数据库中保存的 json 作为参数
|
||||
t := asynq.NewTask(meta.AsynqTask, []byte(s.Payload))
|
||||
|
||||
if _, err := newScheduler.Register(
|
||||
s.Cron,
|
||||
t,
|
||||
asynq.MaxRetry(meta.MaxRetry),
|
||||
asynq.Queue(meta.Queue),
|
||||
); err != nil {
|
||||
// 定时任务配置可能有误(如 Cron 格式不被 Asynq 识别),记录日志并跳过
|
||||
logger.ErrorF(context.Background(), "[Scheduler] 注册定时任务失败 id=%d name=%s: %v", s.ID, s.Name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 替换全局调度器并异步启动
|
||||
activeScheduler = newScheduler
|
||||
go func() {
|
||||
if err := activeScheduler.Run(); err != nil {
|
||||
logger.ErrorF(context.Background(), "[Scheduler] 调度器运行错误: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
logger.InfoF(context.Background(), "[Scheduler] 成功重新加载定时任务,共注册 %d 个活动任务", len(schedules))
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user