fix(server): 修复 task log 接口多个问题

- 修复 Wayne 任务状态映射,将数字状态统一为字符串状态
- 添加分页支持,支持 page/page_size 参数
- 添加 AWXJobStdout 内存缓存,减少重复 API 调用
- 修复 SubscribeTask 内存泄漏,自动清理 closed channel
- 统一 Stream 端点,AWX 使用 pub/sub,Wayne 使用轮询

背景:task log 接口存在多个潜在问题,包括状态判断错误导致无限轮询、
缺少分页、重复 API 调用性能问题、内存泄漏风险等

关联 commit:fix/logs 分支
This commit is contained in:
2026-07-29 14:00:40 +08:00
parent eaba4d237a
commit 61eeb0c8ac
2 changed files with 250 additions and 23 deletions
+98 -6
View File
@@ -332,6 +332,12 @@ func allocatePort(requested int, used []int) (int, error) {
return 0, fmt.Errorf("mysql port pool %d-%d is exhausted on the target host", mysqlPortPoolStart, mysqlPortPoolEnd)
}
// stdoutCacheItem 缓存 AWX Job stdout 的结果
type stdoutCacheItem struct {
stdout string
createdAt time.Time
}
type DeliveryService struct {
db *gorm.DB
cfg config.Config
@@ -340,12 +346,21 @@ type DeliveryService struct {
executionMu sync.Mutex
streamMu sync.Mutex
streams map[string]map[chan DeliveryTaskSnapshot]struct{}
stdoutCache map[string]*stdoutCacheItem
cacheMu sync.RWMutex
}
func (s *DeliveryService) DB() *gorm.DB { return s.db }
func NewDeliveryService(cfg config.Config, db *gorm.DB, audit *AuditService) *DeliveryService {
return &DeliveryService{db: db, cfg: cfg, awx: NewAWXClient(cfg.AWXBaseURL, cfg.AWXToken, cfg.AWXUsername, cfg.AWXPassword), audit: audit, streams: make(map[string]map[chan DeliveryTaskSnapshot]struct{})}
return &DeliveryService{
db: db,
cfg: cfg,
awx: NewAWXClient(cfg.AWXBaseURL, cfg.AWXToken, cfg.AWXUsername, cfg.AWXPassword),
audit: audit,
streams: make(map[string]map[chan DeliveryTaskSnapshot]struct{}),
stdoutCache: make(map[string]*stdoutCacheItem),
}
}
func (s *DeliveryService) ListTargets(ctx context.Context, component string) ([]DeliveryTarget, error) {
@@ -942,18 +957,95 @@ func (s *DeliveryService) broadcastTask(ctx context.Context, taskID string) {
if err != nil {
return
}
// 任务状态变化时清除 stdout 缓存
s.invalidateStdoutCacheForTask(taskID)
s.streamMu.Lock()
defer s.streamMu.Unlock()
// 收集需要清理的 closed channel
var closedChannels []chan DeliveryTaskSnapshot
for ch := range s.streams[taskID] {
select {
case ch <- snapshot:
default:
}
// 使用 recover 捕获 send on closed channel 的错误
func() {
defer func() {
if r := recover(); r != nil {
// channel 已关闭,标记需要清理
closedChannels = append(closedChannels, ch)
}
}()
select {
case ch <- snapshot:
default:
}
}()
}
// 清理 closed channels
for _, ch := range closedChannels {
delete(s.streams[taskID], ch)
}
if len(s.streams[taskID]) == 0 {
delete(s.streams, taskID)
}
}
// invalidateStdoutCacheForTask 清除与任务相关的 stdout 缓存
func (s *DeliveryService) invalidateStdoutCacheForTask(taskID string) {
var execution model.ExecutionJob
var rollback model.RollbackJob
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// 清除 execution job 的缓存
if err := s.db.Where("task_id = ?", taskID).First(&execution).Error; err == nil && execution.ExecutorJobID != "" {
delete(s.stdoutCache, execution.ExecutorJobID)
}
// 清除 rollback job 的缓存
if err := s.db.Where("task_id = ?", taskID).First(&rollback).Error; err == nil && rollback.ExecutorJobID != "" {
delete(s.stdoutCache, rollback.ExecutorJobID)
}
}
const stdoutCacheTTL = 30 * time.Second
func (s *DeliveryService) AWXJobStdout(ctx context.Context, jobID string) (string, error) {
return s.awx.JobStdout(ctx, jobID)
// 检查缓存
s.cacheMu.RLock()
if item, ok := s.stdoutCache[jobID]; ok {
if time.Since(item.createdAt) < stdoutCacheTTL {
s.cacheMu.RUnlock()
return item.stdout, nil
}
}
s.cacheMu.RUnlock()
// 缓存未命中或已过期,重新获取
stdout, err := s.awx.JobStdout(ctx, jobID)
if err != nil {
return "", err
}
// 更新缓存
s.cacheMu.Lock()
s.stdoutCache[jobID] = &stdoutCacheItem{
stdout: stdout,
createdAt: time.Now(),
}
s.cacheMu.Unlock()
return stdout, nil
}
// InvalidateStdoutCache 清除指定 jobID 的 stdout 缓存
func (s *DeliveryService) InvalidateStdoutCache(jobID string) {
s.cacheMu.Lock()
delete(s.stdoutCache, jobID)
s.cacheMu.Unlock()
}
func (s *DeliveryService) Cancel(ctx context.Context, taskID string, userID uint64, isAdmin bool) error {