- 新增 Lit.js 组件化 UI (ui/ 目录) - tasks-chart: 带十字准星和拖拽选择的图表 - queue-table: 队列列表,支持暂停/恢复 - queue-modal: 队列详情弹窗,支持任务重试 - time-range-picker: Prometheus 风格时间选择器 - help-tooltip: 可复用的提示组件 - HTTPHandler 功能 - SSE 实时推送 (stats + queues) - 队列暂停/恢复 API - 任务重试 API - 时间范围查询 API - Inspector 改进 - Prometheus 风格单表存储 - 集成到 Start/Stop 生命周期 - 新增 PauseQueue/UnpauseQueue/RunTask 方法 - 代码重构 - Start 函数拆分为小函数 - 优雅关闭流程优化 - 其他 - 忽略 SQLite 数据库文件 - example 添加延迟/定点任务示例
382 lines
11 KiB
Go
382 lines
11 KiB
Go
// Package taskq 提供基于 Redis 的异步任务队列功能
|
||
// inspect.go 文件包含统计采集器和相关数据结构
|
||
package taskq
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/hibiken/asynq"
|
||
_ "github.com/mattn/go-sqlite3"
|
||
)
|
||
|
||
// ==================== Inspector 统计采集器 ====================
|
||
|
||
// Inspector 统计采集器,独立于 HTTP 服务运行
|
||
type Inspector struct {
|
||
inspector *asynq.Inspector
|
||
db *sql.DB
|
||
closeCh chan struct{}
|
||
closeOnce sync.Once
|
||
interval time.Duration
|
||
}
|
||
|
||
// InspectorOptions 配置统计采集器的选项
|
||
type InspectorOptions struct {
|
||
// Interval 采集间隔,默认 2 秒
|
||
Interval time.Duration
|
||
|
||
// DBPath SQLite 数据库文件路径,默认为 "./taskq_stats.db"
|
||
DBPath string
|
||
}
|
||
|
||
// NewInspector 创建新的统计采集器
|
||
func NewInspector(opts InspectorOptions) (*Inspector, error) {
|
||
if redisClient == nil {
|
||
return nil, fmt.Errorf("taskq: redis client not initialized, call SetRedis() first")
|
||
}
|
||
|
||
if opts.Interval <= 0 {
|
||
opts.Interval = 2 * time.Second
|
||
}
|
||
|
||
if opts.DBPath == "" {
|
||
opts.DBPath = "./taskq_stats.db"
|
||
}
|
||
|
||
// 确保目录存在
|
||
dir := filepath.Dir(opts.DBPath)
|
||
if dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return nil, fmt.Errorf("taskq: failed to create directory: %v", err)
|
||
}
|
||
}
|
||
|
||
// 打开 SQLite 数据库
|
||
db, err := sql.Open("sqlite3", opts.DBPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("taskq: failed to open database: %v", err)
|
||
}
|
||
|
||
// 初始化数据库表
|
||
if err := initStatsDB(db); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("taskq: failed to init database: %v", err)
|
||
}
|
||
|
||
ins := &Inspector{
|
||
inspector: asynq.NewInspectorFromRedisClient(redisClient),
|
||
db: db,
|
||
closeCh: make(chan struct{}),
|
||
interval: opts.Interval,
|
||
}
|
||
|
||
// 启动后台统计采集
|
||
go ins.startCollector()
|
||
|
||
return ins, nil
|
||
}
|
||
|
||
// initStatsDB 初始化数据库(Prometheus 风格:单表 + 标签)
|
||
// 设计思路:
|
||
// - 单表存储所有队列的统计数据,通过 queue 列区分
|
||
// - 复合索引支持按时间和队列两个维度高效查询
|
||
// - 类似 Prometheus 的 (timestamp, labels, value) 模型
|
||
func initStatsDB(db *sql.DB) error {
|
||
_, err := db.Exec(`
|
||
CREATE TABLE IF NOT EXISTS metrics (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
timestamp INTEGER NOT NULL,
|
||
queue TEXT NOT NULL,
|
||
active INTEGER DEFAULT 0,
|
||
pending INTEGER DEFAULT 0,
|
||
scheduled INTEGER DEFAULT 0,
|
||
retry INTEGER DEFAULT 0,
|
||
archived INTEGER DEFAULT 0,
|
||
completed INTEGER DEFAULT 0,
|
||
succeeded INTEGER DEFAULT 0,
|
||
failed INTEGER DEFAULT 0
|
||
);
|
||
-- 按队列查询:WHERE queue = ? ORDER BY timestamp
|
||
CREATE INDEX IF NOT EXISTS idx_metrics_queue_time ON metrics(queue, timestamp DESC);
|
||
-- 按时间查询所有队列:WHERE timestamp BETWEEN ? AND ?
|
||
CREATE INDEX IF NOT EXISTS idx_metrics_time ON metrics(timestamp DESC);
|
||
-- 唯一约束:同一时间同一队列只有一条记录
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_metrics_unique ON metrics(timestamp, queue);
|
||
`)
|
||
return err
|
||
}
|
||
|
||
// Close 关闭统计采集器
|
||
func (ins *Inspector) Close() error {
|
||
ins.closeOnce.Do(func() {
|
||
close(ins.closeCh)
|
||
})
|
||
if ins.db != nil {
|
||
ins.db.Close()
|
||
}
|
||
return ins.inspector.Close()
|
||
}
|
||
|
||
// startCollector 启动后台统计采集任务
|
||
func (ins *Inspector) startCollector() {
|
||
ticker := time.NewTicker(ins.interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ins.closeCh:
|
||
return
|
||
case <-ticker.C:
|
||
ins.collectStats()
|
||
}
|
||
}
|
||
}
|
||
|
||
// collectStats 采集所有队列的统计数据
|
||
func (ins *Inspector) collectStats() {
|
||
now := time.Now().Unix()
|
||
|
||
for queueName := range queues {
|
||
stats, err := ins.inspector.GetQueueInfo(queueName)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
qs := QueueStats{
|
||
Queue: queueName,
|
||
Timestamp: now,
|
||
Active: stats.Active,
|
||
Pending: stats.Pending,
|
||
Scheduled: stats.Scheduled,
|
||
Retry: stats.Retry,
|
||
Archived: stats.Archived,
|
||
Completed: stats.Completed,
|
||
Succeeded: stats.Processed - stats.Failed,
|
||
Failed: stats.Failed,
|
||
}
|
||
|
||
ins.saveMetrics(qs)
|
||
}
|
||
}
|
||
|
||
// saveMetrics 保存统计数据到 metrics 表
|
||
func (ins *Inspector) saveMetrics(stats QueueStats) error {
|
||
if ins.db == nil {
|
||
return nil
|
||
}
|
||
|
||
_, err := ins.db.Exec(`
|
||
INSERT OR REPLACE INTO metrics (timestamp, queue, active, pending, scheduled, retry, archived, completed, succeeded, failed)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, stats.Timestamp, stats.Queue, stats.Active, stats.Pending, stats.Scheduled, stats.Retry, stats.Archived, stats.Completed, stats.Succeeded, stats.Failed)
|
||
|
||
return err
|
||
}
|
||
|
||
// GetQueueInfo 获取队列信息
|
||
func (ins *Inspector) GetQueueInfo(queueName string) (*asynq.QueueInfo, error) {
|
||
return ins.inspector.GetQueueInfo(queueName)
|
||
}
|
||
|
||
// ListActiveTasks 获取活跃任务列表
|
||
func (ins *Inspector) ListActiveTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
|
||
return ins.inspector.ListActiveTasks(queueName, opts...)
|
||
}
|
||
|
||
// ListPendingTasks 获取等待任务列表
|
||
func (ins *Inspector) ListPendingTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
|
||
return ins.inspector.ListPendingTasks(queueName, opts...)
|
||
}
|
||
|
||
// ListScheduledTasks 获取计划任务列表
|
||
func (ins *Inspector) ListScheduledTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
|
||
return ins.inspector.ListScheduledTasks(queueName, opts...)
|
||
}
|
||
|
||
// ListRetryTasks 获取重试任务列表
|
||
func (ins *Inspector) ListRetryTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
|
||
return ins.inspector.ListRetryTasks(queueName, opts...)
|
||
}
|
||
|
||
// ListArchivedTasks 获取归档任务列表
|
||
func (ins *Inspector) ListArchivedTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
|
||
return ins.inspector.ListArchivedTasks(queueName, opts...)
|
||
}
|
||
|
||
// ListCompletedTasks 获取已完成任务列表
|
||
func (ins *Inspector) ListCompletedTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
|
||
return ins.inspector.ListCompletedTasks(queueName, opts...)
|
||
}
|
||
|
||
// RunTask 立即运行归档任务(重试失败任务)
|
||
func (ins *Inspector) RunTask(queueName, taskID string) error {
|
||
return ins.inspector.RunTask(queueName, taskID)
|
||
}
|
||
|
||
// PauseQueue 暂停队列
|
||
func (ins *Inspector) PauseQueue(queueName string) error {
|
||
return ins.inspector.PauseQueue(queueName)
|
||
}
|
||
|
||
// UnpauseQueue 恢复队列
|
||
func (ins *Inspector) UnpauseQueue(queueName string) error {
|
||
return ins.inspector.UnpauseQueue(queueName)
|
||
}
|
||
|
||
// ==================== 统计数据结构 ====================
|
||
|
||
// QueueInfo 获取每个队列的详细信息
|
||
type QueueInfo struct {
|
||
Name string `json:"name"`
|
||
Priority int `json:"priority"`
|
||
Size int `json:"size"` // 队列中任务总数
|
||
Active int `json:"active"` // 活跃任务数
|
||
Pending int `json:"pending"` // 等待任务数
|
||
Scheduled int `json:"scheduled"` // 计划任务数
|
||
Retry int `json:"retry"` // 重试任务数
|
||
Archived int `json:"archived"` // 归档任务数
|
||
Completed int `json:"completed"` // 已完成任务数
|
||
Processed int `json:"processed"` // 累计处理数(今日)
|
||
Failed int `json:"failed"` // 累计失败数(今日)
|
||
Paused bool `json:"paused"` // 是否暂停
|
||
MemoryUsage int64 `json:"memory_usage"` // 内存使用(字节)
|
||
Latency int64 `json:"latency"` // 延迟(毫秒)
|
||
}
|
||
|
||
// QueueStats 队列统计数据点(用于存储历史数据)
|
||
type QueueStats struct {
|
||
Timestamp int64 `json:"t"` // Unix 时间戳(秒)
|
||
Queue string `json:"q,omitempty"` // 队列名称(汇总查询时为空)
|
||
Active int `json:"a"` // 活跃任务数
|
||
Pending int `json:"p"` // 等待任务数
|
||
Scheduled int `json:"s"` // 计划任务数
|
||
Retry int `json:"r"` // 重试任务数
|
||
Archived int `json:"ar"` // 归档任务数
|
||
Completed int `json:"c"` // 已完成任务数
|
||
Succeeded int `json:"su"` // 成功数
|
||
Failed int `json:"f"` // 失败数
|
||
}
|
||
|
||
// ==================== 全局统计数据查询 ====================
|
||
|
||
var statsDB *sql.DB
|
||
var statsDBMu sync.RWMutex
|
||
|
||
// SetStatsDB 设置全局统计数据库(供 HTTPHandler 使用)
|
||
func SetStatsDB(db *sql.DB) {
|
||
statsDBMu.Lock()
|
||
defer statsDBMu.Unlock()
|
||
statsDB = db
|
||
}
|
||
|
||
// StatsQuery 统计查询参数
|
||
type StatsQuery struct {
|
||
Queue string // 队列名称,为空则查询所有队列汇总
|
||
Start int64 // 开始时间戳(秒),0 表示不限制
|
||
End int64 // 结束时间戳(秒),0 表示不限制
|
||
Limit int // 返回数量限制,默认 500
|
||
}
|
||
|
||
// getQueueStats 获取队列历史统计数据
|
||
func getQueueStats(queueName string, limit int) ([]QueueStats, error) {
|
||
return getQueueStatsWithQuery(StatsQuery{
|
||
Queue: queueName,
|
||
Limit: limit,
|
||
})
|
||
}
|
||
|
||
// getQueueStatsWithQuery 根据查询条件获取统计数据(Prometheus 风格单表查询)
|
||
// - 按队列查询:使用 idx_metrics_queue_time 索引
|
||
// - 按时间汇总:使用 idx_metrics_time 索引 + GROUP BY
|
||
func getQueueStatsWithQuery(q StatsQuery) ([]QueueStats, error) {
|
||
statsDBMu.RLock()
|
||
db := statsDB
|
||
statsDBMu.RUnlock()
|
||
|
||
if db == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
if q.Limit <= 0 {
|
||
q.Limit = 500
|
||
}
|
||
|
||
var args []any
|
||
var whereClause string
|
||
var conditions []string
|
||
|
||
// 构建 WHERE 条件
|
||
if q.Queue != "" {
|
||
conditions = append(conditions, "queue = ?")
|
||
args = append(args, q.Queue)
|
||
}
|
||
if q.Start > 0 {
|
||
conditions = append(conditions, "timestamp >= ?")
|
||
args = append(args, q.Start)
|
||
}
|
||
if q.End > 0 {
|
||
conditions = append(conditions, "timestamp <= ?")
|
||
args = append(args, q.End)
|
||
}
|
||
|
||
if len(conditions) > 0 {
|
||
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||
}
|
||
|
||
var query string
|
||
if q.Queue != "" {
|
||
// 查询单个队列
|
||
query = fmt.Sprintf(`
|
||
SELECT timestamp, queue, active, pending, scheduled, retry, archived, completed, succeeded, failed
|
||
FROM metrics
|
||
%s
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`, whereClause)
|
||
} else {
|
||
// 查询所有队列汇总(按时间 GROUP BY)
|
||
query = fmt.Sprintf(`
|
||
SELECT timestamp, '' as queue, SUM(active), SUM(pending), SUM(scheduled), SUM(retry), SUM(archived), SUM(completed), SUM(succeeded), SUM(failed)
|
||
FROM metrics
|
||
%s
|
||
GROUP BY timestamp
|
||
ORDER BY timestamp DESC
|
||
LIMIT ?
|
||
`, whereClause)
|
||
}
|
||
args = append(args, q.Limit)
|
||
|
||
rows, err := db.Query(query, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var statsList []QueueStats
|
||
for rows.Next() {
|
||
var s QueueStats
|
||
if err := rows.Scan(&s.Timestamp, &s.Queue, &s.Active, &s.Pending, &s.Scheduled, &s.Retry, &s.Archived, &s.Completed, &s.Succeeded, &s.Failed); err != nil {
|
||
continue
|
||
}
|
||
statsList = append(statsList, s)
|
||
}
|
||
|
||
// 反转顺序,使时间从早到晚
|
||
for i, j := 0, len(statsList)-1; i < j; i, j = i+1, j-1 {
|
||
statsList[i], statsList[j] = statsList[j], statsList[i]
|
||
}
|
||
|
||
return statsList, nil
|
||
}
|
||
|
||
// GetStatsDB 返回 Inspector 的数据库连接(供外部设置给 HTTPHandler)
|
||
func (ins *Inspector) GetStatsDB() *sql.DB {
|
||
return ins.db
|
||
}
|