fix(task): execute dispatched jobs and persist run records

Asynq func handlers now go through ProcessTask so admin execution
rows leave pending. The in-process worker resolves admin type
identifiers and writes the same w_task_executions table. Remove
the no-op admin system_cleanup that shadowed the upload handler.
This commit is contained in:
ryan
2026-09-02 16:11:45 +08:00
parent 33f28ad671
commit 30bbe965bf
14 changed files with 530 additions and 82 deletions
+4
View File
@@ -221,6 +221,10 @@ func TestTaskExtension(t *testing.T) {
assert.True(t, ok)
assert.Equal(t, "order:cancel_timeout", task.Pattern)
byType, ok := tr.Get("cancel_timeout")
assert.True(t, ok, "Get should resolve admin type identifier")
assert.Equal(t, "order:cancel_timeout", byType.Pattern)
_, ok = tr.Get("unknown")
assert.False(t, ok)
}
+10 -3
View File
@@ -276,10 +276,17 @@ func (t *TaskRegistry) Tasks() []TaskDefinition {
return res
}
// Get retrieves a task definition by its pattern.
// Get retrieves a task definition by its pattern or admin type identifier.
func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
td, ok := t.lookup[pattern]
return td, ok
if td, ok := t.lookup[pattern]; ok {
return td, true
}
for _, td := range t.tasks {
if td.Type == pattern {
return td, true
}
}
return TaskDefinition{}, false
}
+1 -16
View File
@@ -181,22 +181,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return err
}, extpoints.WithTaskMeta(service.LogDBSwitchMeta))
ctx.Task().Register("admin:system_cleanup", func(_ context.Context, _ []byte) error {
return nil
},
extpoints.WithTaskType("system_cleanup"),
extpoints.WithTaskName("系统垃圾清理"),
extpoints.WithTaskDescription("定期清理未使用上传文件、历史推送记录和过期任务执行日志"),
extpoints.WithTaskCategory("maintenance"),
extpoints.WithTaskRetry(1),
extpoints.WithTaskQueue("default"),
extpoints.WithTaskRetryable(true),
)
// 3. Register Cron Schedules
ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"})
// 4. Register Settings Schemas
// 3. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "admin.system_cleanup_cron",
Default: "0 4 * * *",
+1 -6
View File
@@ -33,14 +33,9 @@ func TestAdminPluginUnit(t *testing.T) {
assert.True(t, hasRobots, "admin plugin must register /robots.txt")
// Verify tasks
_, ok := ctx.Tasks().Get("admin:system_cleanup")
_, ok := ctx.Tasks().Get("logs:db_switch")
require.True(t, ok)
// Verify schedules
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", sched.Spec)
// Verify settings
setting, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
+3 -6
View File
@@ -380,12 +380,9 @@ func TestAdminPlugin(t *testing.T) {
assert.True(t, hasTasks)
assert.True(t, hasConfigs)
// 2. Task & Schedule
_, ok := ctx.Tasks().Get("admin:system_cleanup")
// 2. Task
_, ok := ctx.Tasks().Get("logs:db_switch")
require.True(t, ok)
sched, ok := ctx.Schedules().Get("admin:system_cleanup")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", sched.Spec)
// 3. Settings
schema, ok := ctx.Settings().Get("admin.system_cleanup_cron")
@@ -455,7 +452,7 @@ func TestAllDomainPluginsCombined(t *testing.T) {
// Verify total schedules registered
allSchedules := ctx.Schedules().Schedules()
assert.GreaterOrEqual(t, len(allSchedules), 2)
assert.GreaterOrEqual(t, len(allSchedules), 1)
// 每个调度指向的任务类型都必须已注册 Handler,否则触发时会投递到无人处理的
// 任务类型,预期的清理逻辑静默失效。
@@ -265,8 +265,9 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
taskID := t.ResultWriter().TaskID()
// 注入 taskID 到 context
// 注入 taskID 到 context,并把原始 asynq.Task 交给适配器 Handler。
ctx = withTaskID(ctx, taskID)
ctx = context.WithValue(ctx, asynqTaskCtxKey{}, t)
// 查找处理器
handler, ok := getHandler(t.Type())
@@ -277,18 +278,20 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error {
return err
}
// 加载或动态创建执行记录
// 加载或动态创建执行记录。无 DB 时仍执行业务 Handler,避免测试/精简拓扑 panic。
var execution *TaskExecution
now := time.Now()
execution, err := getOrCreateTaskExecution(ctx, taskID, t, taskPayload, now)
if err == nil {
updateExecutionOnStart(ctx, execution, now)
if getDB(ctx) != nil {
var err error
execution, err = getOrCreateTaskExecution(ctx, taskID, t, taskPayload, now)
if err == nil {
updateExecutionOnStart(ctx, execution, now)
}
}
if execution != nil {
AppendLog(ctx, "[系统] 开始执行异步任务 [名称: %s, 类型: %s],重试次数: %d/%d",
execution.TaskName, t.Type(), execution.RetryCount, execution.MaxRetry)
} else {
AppendLog(ctx, "[系统] 开始执行异步任务 [类型: %s]", t.Type())
}
// 开始计时
@@ -192,7 +192,7 @@ func (p *Plugin) Start(_ context.Context) error {
if p.coreCtx != nil && p.coreCtx.Tasks() != nil {
for _, td := range p.coreCtx.Tasks().Tasks() {
handler, err := toAsynqHandler(td.Handler)
handler, err := toAsynqHandler(td.Pattern, td.Handler)
if err != nil {
return fmt.Errorf("driver_asynq_worker: invalid handler for task pattern %q: %w", td.Pattern, err)
}
@@ -276,21 +276,30 @@ func (p *Plugin) Mux() *asynq.ServeMux {
return p.mux
}
func toAsynqHandler(h any) (asynq.Handler, error) {
func toAsynqHandler(pattern string, h any) (asynq.Handler, error) {
if h == nil {
return nil, errors.New("nil handler")
}
if th, ok := h.(TaskHandler); ok {
RegisterHandler(pattern, th)
return asynq.HandlerFunc(ProcessTask), nil
}
inner, err := toRawAsynqHandler(h)
if err != nil {
return nil, err
}
RegisterHandler(pattern, &asynqHandlerAdapter{inner: inner})
return asynq.HandlerFunc(ProcessTask), nil
}
func toRawAsynqHandler(h any) (asynq.Handler, error) {
switch fn := h.(type) {
case asynq.HandlerFunc:
return fn, nil
case asynq.Handler:
return fn, nil
case TaskHandler:
return asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error {
RegisterHandler(t.Type(), fn)
return ProcessTask(c, t)
}), nil
case func(context.Context, *asynq.Task) error:
return asynq.HandlerFunc(fn), nil
case func(context.Context, []byte) error:
@@ -316,6 +325,23 @@ func toAsynqHandler(h any) (asynq.Handler, error) {
}
}
type asynqTaskCtxKey struct{}
type asynqHandlerAdapter struct {
inner asynq.Handler
}
func (a *asynqHandlerAdapter) Execute(ctx context.Context, payload []byte) (*TaskResult, error) {
t, _ := ctx.Value(asynqTaskCtxKey{}).(*asynq.Task)
if t == nil {
t = asynq.NewTask("", payload)
}
if err := a.inner.ProcessTask(ctx, t); err != nil {
return nil, err
}
return &TaskResult{Message: "ok"}, nil
}
type taskServiceImpl struct{}
func (s *taskServiceImpl) Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
@@ -0,0 +1,39 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_inproc_worker
import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"sync"
"gorm.io/gorm"
)
var (
dbMu sync.RWMutex
dbSvc contracts.DBService
)
func setDBService(s contracts.DBService) {
dbMu.Lock()
defer dbMu.Unlock()
dbSvc = s
}
func getDB(ctx context.Context) *gorm.DB {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
return s.DB(ctx)
}
}
dbMu.RLock()
s := dbSvc
dbMu.RUnlock()
if s != nil {
return s.DB(ctx)
}
return nil
}
@@ -6,6 +6,7 @@ package driver_inproc_worker
import (
"Wavelet/core/extpoints"
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"context"
"errors"
@@ -59,34 +60,56 @@ func NewInprocQueue(concurrency, queueCap int, taskReg extpoints.TaskExtension)
}
// Enqueue puts a new task into the in-process queue.
func (q *InprocQueue) Enqueue(taskType string, payload []byte, source string) (string, error) {
// taskType may be the registration pattern or the admin type identifier.
func (q *InprocQueue) Enqueue(ctx context.Context, taskType string, payload []byte, source string) (string, error) {
if !q.running.Load() {
return "", errors.New("driver_inproc_worker: queue is not running")
}
taskID := fmt.Sprintf("inproc_%d", idgen.NextUint64ID())
td, ok := q.lookupTask(taskType)
if !ok {
return "", fmt.Errorf("driver_inproc_worker: unknown task type %q", taskType)
}
if source == "" {
source = "manual"
}
idType := td.Type
if idType == "" {
idType = td.Pattern
}
taskID := fmt.Sprintf("%s_%s_%d", source, idType, idgen.NextUint64ID())
msg := TaskMessage{
ID: taskID,
TaskType: taskType,
TaskType: td.Pattern,
Payload: payload,
Source: source,
CreatedAt: time.Now(),
RetryLeft: td.Retry,
}
if q.taskReg != nil {
if td, ok := q.taskReg.Get(taskType); ok {
msg.RetryLeft = td.Retry
}
if err := q.createExecution(ctx, msg, td); err != nil {
return "", err
}
q.appendExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (最大重试次数: %d)", td.Retry))
select {
case q.queue <- msg:
return taskID, nil
default:
q.failExecution(ctx, msg, errors.New("queue is full"), 0)
return "", errors.New("driver_inproc_worker: queue is full")
}
}
func (q *InprocQueue) lookupTask(taskType string) (extpoints.TaskDefinition, bool) {
if q.taskReg == nil {
return extpoints.TaskDefinition{}, false
}
return q.taskReg.Get(taskType)
}
// Start begins processing tasks with the worker pool. ctx is the app-lifetime
// context used as the parent for per-task execution contexts.
func (q *InprocQueue) Start(ctx context.Context) {
@@ -145,12 +168,10 @@ func (q *InprocQueue) workerLoop(ctx context.Context) {
}
func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) {
if q.taskReg == nil {
return
}
td, ok := q.taskReg.Get(msg.TaskType)
td, ok := q.lookupTask(msg.TaskType)
if !ok {
logger.ErrorF(ctx, "driver_inproc_worker: no handler for task %q", msg.TaskType)
q.failExecution(ctx, msg, fmt.Errorf("unregistered task handler: %s", msg.TaskType), 0)
return
}
@@ -162,26 +183,34 @@ func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) {
taskCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
q.markRunning(ctx, msg)
start := time.Now()
err := invokeHandler(taskCtx, td.Handler, msg.Payload)
if err != nil && msg.RetryLeft > 0 {
msg.RetryLeft--
// Retry with backoff
util.Go(func() {
select {
case <-time.After(defaultRetryBackoff):
case <-q.stopCh:
return
case <-ctx.Done():
return
}
if q.running.Load() {
duration := time.Since(start)
if err != nil {
q.failExecution(ctx, msg, err, duration)
if msg.RetryLeft > 0 {
msg.RetryLeft--
util.Go(func() {
select {
case q.queue <- msg:
default:
case <-time.After(defaultRetryBackoff):
case <-q.stopCh:
return
case <-ctx.Done():
return
}
}
})
if q.running.Load() {
select {
case q.queue <- msg:
default:
}
}
})
}
return
}
q.succeedExecution(ctx, msg, duration)
}
func invokeHandler(ctx context.Context, handler any, payload []byte) error {
@@ -202,3 +231,106 @@ func invokeHandler(ctx context.Context, handler any, payload []byte) error {
return fmt.Errorf("unsupported handler type: %T", handler)
}
}
func (q *InprocQueue) createExecution(ctx context.Context, msg TaskMessage, td extpoints.TaskDefinition) error {
db := getDB(ctx)
if db == nil {
return nil
}
name := td.Name
if name == "" {
name = td.DisplayName
}
if name == "" {
name = td.Pattern
}
exec := &taskExecution{
ID: idgen.NextUint64ID(),
TaskID: msg.ID,
TaskType: td.Pattern,
TaskName: name,
Status: taskExecutionStatusPending,
Retryable: td.Retryable || td.Retry > 0,
MaxRetry: td.Retry,
RetryCount: 0,
Payload: string(msg.Payload),
TriggeredBy: msg.Source,
}
if err := db.Create(exec).Error; err != nil {
return fmt.Errorf("driver_inproc_worker: create task execution: %w", err)
}
return nil
}
func (q *InprocQueue) markRunning(ctx context.Context, msg TaskMessage) {
db := getDB(ctx)
if db == nil {
return
}
now := time.Now()
updates := map[string]any{
taskExecutionColStatus: taskExecutionStatusRunning,
"started_at": now,
}
if err := db.Model(&taskExecution{}).Where("task_id = ?", msg.ID).Updates(updates).Error; err != nil {
logger.ErrorF(ctx, "driver_inproc_worker: mark running failed taskID=%s: %v", msg.ID, err)
return
}
q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 开始执行异步任务 [类型: %s]", msg.TaskType))
}
func (q *InprocQueue) succeedExecution(ctx context.Context, msg TaskMessage, duration time.Duration) {
db := getDB(ctx)
if db == nil {
return
}
now := time.Now()
updates := map[string]any{
taskExecutionColStatus: taskExecutionStatusSucceeded,
"error_message": "",
"result": "ok",
"finished_at": now,
"duration": duration.Milliseconds(),
}
if err := db.Model(&taskExecution{}).Where("task_id = ?", msg.ID).Updates(updates).Error; err != nil {
logger.ErrorF(ctx, "driver_inproc_worker: mark succeeded failed taskID=%s: %v", msg.ID, err)
return
}
q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 任务执行成功,耗时: %d ms", duration.Milliseconds()))
}
func (q *InprocQueue) failExecution(ctx context.Context, msg TaskMessage, execErr error, duration time.Duration) {
db := getDB(ctx)
if db == nil {
return
}
now := time.Now()
updates := map[string]any{
taskExecutionColStatus: taskExecutionStatusFailed,
"error_message": execErr.Error(),
"finished_at": now,
"duration": duration.Milliseconds(),
}
if err := db.Model(&taskExecution{}).Where("task_id = ?", msg.ID).Updates(updates).Error; err != nil {
logger.ErrorF(ctx, "driver_inproc_worker: mark failed failed taskID=%s: %v", msg.ID, err)
return
}
q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 任务执行失败,耗时: %d ms,错误原因: %v", duration.Milliseconds(), execErr))
}
func (q *InprocQueue) appendExecutionLog(ctx context.Context, taskID, logLine string) {
db := getDB(ctx)
if db == nil {
return
}
now := time.Now().Format("15:04:05")
line := fmt.Sprintf("[%s] %s\n", now, logLine)
var exec taskExecution
if err := db.Where("task_id = ?", taskID).First(&exec).Error; err != nil {
return
}
if err := db.Model(&exec).Update("log", exec.Log+line).Error; err != nil {
logger.ErrorF(ctx, "driver_inproc_worker: append log failed taskID=%s: %v", taskID, err)
}
}
@@ -8,6 +8,7 @@ import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"errors"
"sync"
"time"
)
@@ -24,15 +25,15 @@ var (
)
// DispatchTask enqueues a background task to the active in-process worker queue.
func DispatchTask(_ context.Context, taskType string, payload []byte, source string) (string, error) {
func DispatchTask(ctx context.Context, taskType string, payload []byte, source string) (string, error) {
globalMu.RLock()
q := globalQueue
globalMu.RUnlock()
if q == nil {
return "", nil
return "", errors.New("driver_inproc_worker: queue is not running")
}
return q.Enqueue(taskType, payload, source)
return q.Enqueue(ctx, taskType, payload, source)
}
// Option configures the in-process worker driver plugin.
@@ -118,10 +119,19 @@ func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
func (p *Plugin) Apply(ctx *core.Context) error {
p.coreCtx = ctx
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
setDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
setDBService(db)
})
}
taskSvc := newInprocTaskService(ctx.Tasks())
core.Provide[contracts.TaskService](ctx, taskSvc)
ctx.OnDispose(func() error {
setDBService(nil)
shutdownCtx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout)
defer cancel()
return p.Stop(shutdownCtx)
@@ -5,8 +5,10 @@ package driver_inproc_worker_test
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/idgen"
"Wavelet/pkg/testhelper"
"Wavelet/plugins/drivers/driver_inproc_worker"
"context"
"sync/atomic"
@@ -15,8 +17,19 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type testDBService struct {
db *gorm.DB
}
func (m *testDBService) GORM() *gorm.DB { return m.db }
func (m *testDBService) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) }
func (m *testDBService) Named(_ string) *gorm.DB { return m.db }
func TestInprocWorkerPlugin(t *testing.T) {
require.NoError(t, idgen.Init(1))
ctx := core.NewContext(context.Background())
@@ -51,3 +64,53 @@ func TestInprocWorkerPlugin(t *testing.T) {
// Stop driver
require.NoError(t, p.Stop(context.Background()))
}
func TestInprocWorkerDispatchByTypeTracksExecution(t *testing.T) {
require.NoError(t, idgen.Init(1))
testDB, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := core.NewContext(context.Background())
core.Provide[contracts.DBService](ctx, &testDBService{db: testDB})
p := driver_inproc_worker.New(
driver_inproc_worker.WithConcurrency(2),
driver_inproc_worker.WithShutdownTimeout(time.Second),
)
require.NoError(t, p.Apply(ctx))
var executedCount atomic.Int32
ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error {
executedCount.Add(1)
return nil
},
extpoints.WithTaskType("system_cleanup"),
extpoints.WithTaskName("系统垃圾清理"),
extpoints.WithTaskRetry(1),
extpoints.WithTaskRetryable(true),
)
require.NoError(t, p.Start(context.Background()))
t.Cleanup(func() {
_ = p.Stop(context.Background())
})
taskID, err := driver_inproc_worker.DispatchTask(context.Background(), "system_cleanup", []byte("payload"), "manual")
require.NoError(t, err)
assert.NotEmpty(t, taskID)
require.Eventually(t, func() bool {
return executedCount.Load() == 1
}, 2*time.Second, 20*time.Millisecond, "inproc worker should execute task dispatched by admin type")
taskSvc, err := core.Inject[contracts.TaskService](ctx)
require.NoError(t, err)
require.Eventually(t, func() bool {
execs, total, listErr := taskSvc.ListExecutions(context.Background(), "", "", 1, 10)
if listErr != nil || total == 0 || len(execs) == 0 {
return false
}
return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].TaskName == "系统垃圾清理"
}, 2*time.Second, 20*time.Millisecond, "inproc worker should persist a succeeded execution record")
}
@@ -25,8 +25,22 @@ func (s *inprocTaskService) Dispatch(ctx context.Context, taskType string, paylo
return DispatchTask(ctx, taskType, payload, triggeredBy)
}
func (s *inprocTaskService) Retry(_ context.Context, id uint64) (string, error) {
return fmt.Sprintf("inproc_retry_%d", id), nil
func (s *inprocTaskService) Retry(ctx context.Context, id uint64) (string, error) {
db := getDB(ctx)
if db == nil {
return "", errors.New("driver_inproc_worker: db not initialized")
}
var exec taskExecution
if err := db.Where("id = ?", id).First(&exec).Error; err != nil {
return "", fmt.Errorf("driver_inproc_worker: task execution not found: %w", err)
}
if exec.Status != taskExecutionStatusFailed {
return "", fmt.Errorf("driver_inproc_worker: only failed tasks can be retried, current status: %s", exec.Status)
}
if !exec.Retryable {
return "", errors.New("driver_inproc_worker: task is not retryable")
}
return DispatchTask(ctx, exec.TaskType, []byte(exec.Payload), "retry")
}
func (s *inprocTaskService) ListTasks() []contracts.TaskMetaDTO {
@@ -64,10 +78,72 @@ func (s *inprocTaskService) ReloadScheduler() error {
func (s *inprocTaskService) AppendLog(_ context.Context, _ string, _ ...any) {
}
func (s *inprocTaskService) ListExecutions(_ context.Context, _, _ string, _, _ int) ([]contracts.TaskExecutionDTO, int64, error) {
return []contracts.TaskExecutionDTO{}, 0, nil
func (s *inprocTaskService) ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]contracts.TaskExecutionDTO, int64, error) {
db := getDB(ctx)
if db == nil {
return []contracts.TaskExecutionDTO{}, 0, nil
}
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
query := db.Model(&taskExecution{})
if taskType != "" {
query = query.Where("task_type = ?", taskType)
}
if status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []taskExecution
offset := (page - 1) * pageSize
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, 0, err
}
res := make([]contracts.TaskExecutionDTO, 0, len(rows))
for i := range rows {
res = append(res, toExecutionDTO(&rows[i]))
}
return res, total, nil
}
func (s *inprocTaskService) GetExecution(_ context.Context, _ uint64) (*contracts.TaskExecutionDTO, error) {
return nil, errors.New("driver_inproc_worker: task executions are not tracked")
func (s *inprocTaskService) GetExecution(ctx context.Context, id uint64) (*contracts.TaskExecutionDTO, error) {
db := getDB(ctx)
if db == nil {
return nil, errors.New("driver_inproc_worker: db not initialized")
}
var exec taskExecution
if err := db.Where("id = ?", id).First(&exec).Error; err != nil {
return nil, err
}
dto := toExecutionDTO(&exec)
return &dto, nil
}
func toExecutionDTO(exec *taskExecution) contracts.TaskExecutionDTO {
return contracts.TaskExecutionDTO{
ID: exec.ID,
TaskID: exec.TaskID,
TaskType: exec.TaskType,
TaskName: exec.TaskName,
Status: string(exec.Status),
Retryable: exec.Retryable,
MaxRetry: exec.MaxRetry,
RetryCount: exec.RetryCount,
Log: exec.Log,
ErrorMessage: exec.ErrorMessage,
Result: exec.Result,
StartedAt: exec.StartedAt,
FinishedAt: exec.FinishedAt,
Duration: exec.Duration,
Payload: exec.Payload,
TriggeredBy: exec.TriggeredBy,
CreatedAt: exec.CreatedAt,
UpdatedAt: exec.UpdatedAt,
}
}
@@ -0,0 +1,44 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_inproc_worker
import "time"
type taskExecutionStatus string
const (
taskExecutionStatusPending taskExecutionStatus = "pending"
taskExecutionStatusRunning taskExecutionStatus = "running"
taskExecutionStatusSucceeded taskExecutionStatus = "succeeded"
taskExecutionStatusFailed taskExecutionStatus = "failed"
taskExecutionColStatus = "status"
)
// taskExecution maps to the admin-owned w_task_executions table so the
// console can list in-process runs the same way it lists Asynq runs.
type taskExecution struct {
ID uint64 `gorm:"primaryKey"`
TaskID string `gorm:"size:128;uniqueIndex;not null"`
TaskType string `gorm:"size:64;index;not null"`
TaskName string `gorm:"size:128"`
Status taskExecutionStatus `gorm:"size:32;index;not null"`
Retryable bool `gorm:"not null;default:false"`
MaxRetry int `gorm:"not null;default:0"`
RetryCount int `gorm:"not null;default:0"`
Log string `gorm:"type:text"`
ErrorMessage string `gorm:"type:text"`
Result string `gorm:"type:text"`
StartedAt *time.Time `gorm:"index"`
FinishedAt *time.Time
Duration int64 `gorm:"comment:耗时毫秒"`
Payload string `gorm:"type:text"`
TriggeredBy string `gorm:"size:32;not null;default:system"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (taskExecution) TableName() string {
return "w_task_executions"
}
+67
View File
@@ -5,7 +5,10 @@ package drivers_test
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/idgen"
"Wavelet/pkg/testhelper"
"Wavelet/plugins/drivers/driver_asynq_cron"
"Wavelet/plugins/drivers/driver_asynq_worker"
"Wavelet/plugins/drivers/driver_http"
@@ -24,8 +27,19 @@ import (
"github.com/hibiken/asynq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type testDBService struct {
db *gorm.DB
}
func (m *testDBService) GORM() *gorm.DB { return m.db }
func (m *testDBService) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) }
func (m *testDBService) Named(_ string) *gorm.DB { return m.db }
func init() {
gin.SetMode(gin.TestMode)
}
@@ -214,6 +228,59 @@ func TestAsynqWorkerDriverLifecycle(t *testing.T) {
require.NoError(t, err)
}
func TestAsynqWorkerDispatchTracksExecution(t *testing.T) {
_ = idgen.Init(1)
testDB, mr, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := core.NewContext(context.Background())
ctx.Config().SetSource(core.NewMapSource(nil))
require.NoError(t, ctx.Config().Resolve())
core.Provide[contracts.DBService](ctx, &testDBService{db: testDB})
var processed atomic.Bool
ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error {
processed.Store(true)
return nil
},
extpoints.WithTaskType("system_cleanup"),
extpoints.WithTaskName("系统垃圾清理"),
extpoints.WithTaskQueue("default"),
extpoints.WithTaskRetry(1),
extpoints.WithTaskRetryable(true),
)
workerPlugin := driver_asynq_worker.New(
driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}),
driver_asynq_worker.WithConcurrency(2),
driver_asynq_worker.WithShutdownTimeout(2*time.Second),
)
require.NoError(t, workerPlugin.Apply(ctx))
require.NoError(t, workerPlugin.Start(context.Background()))
t.Cleanup(func() {
_ = workerPlugin.Stop(context.Background())
})
taskSvc, err := core.Inject[contracts.TaskService](ctx)
require.NoError(t, err)
taskID, err := taskSvc.Dispatch(context.Background(), "system_cleanup", []byte(`{}`), "manual")
require.NoError(t, err)
require.NotEmpty(t, taskID)
require.Eventually(t, func() bool {
return processed.Load()
}, 5*time.Second, 50*time.Millisecond, "asynq worker should execute dispatched func handler")
require.Eventually(t, func() bool {
execs, _, listErr := taskSvc.ListExecutions(context.Background(), "", "", 1, 10)
if listErr != nil || len(execs) == 0 {
return false
}
return execs[0].TaskID == taskID && execs[0].Status == "succeeded"
}, 5*time.Second, 50*time.Millisecond, "task execution should become succeeded after worker runs")
}
func TestAsynqCronDriverLifecycle(t *testing.T) {
mr, err := miniredis.Run()
require.NoError(t, err)