Files
xinfra/server/internal/service/delivery_task_test.go
T
wonder 0027704169 fix(server): 修复 TestSubscribeTask_ConcurrentSafety 数据竞争问题
- 修复多个 goroutine 并发 append 同一 slice 导致的 DATA RACE
- 使用预分配 slice + 索引赋值替代并发 append
- 通过 go test -race 验证
2026-07-29 14:47:31 +08:00

441 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"sync"
"testing"
"time"
"github.com/1024XEngineer/xinfra/server/internal/model"
)
// newTestDeliveryService 创建一个用于测试的 DeliveryService,不需要真实的 DB 和配置。
// 仅适用于测试 pub/sub、缓存等内存逻辑。
func newTestDeliveryService() *DeliveryService {
return &DeliveryService{
streams: make(map[string]map[chan DeliveryTaskSnapshot]struct{}),
stdoutCache: make(map[string]*stdoutCacheItem),
}
}
func TestSubscribeTask(t *testing.T) {
svc := newTestDeliveryService()
ctx := context.Background()
_ = ctx
taskID := "test-task-1"
// 订阅任务
ch, cancel := svc.SubscribeTask(taskID)
defer cancel()
// 验证 channel 已注册
svc.streamMu.Lock()
if _, ok := svc.streams[taskID]; !ok {
svc.streamMu.Unlock()
t.Fatal("SubscribeTask did not register channel in streams map")
}
svc.streamMu.Unlock()
// 模拟 broadcastTask 推送 snapshot
snapshot := DeliveryTaskSnapshot{
Task: &model.DeliveryTask{ID: taskID, Status: model.TaskRunning},
}
svc.streamMu.Lock()
for ch := range svc.streams[taskID] {
select {
case ch <- snapshot:
default:
}
}
svc.streamMu.Unlock()
// 接收推送
select {
case received := <-ch:
if received.Task == nil || received.Task.ID != taskID {
t.Fatalf("received snapshot task ID = %v, want %q", received.Task, taskID)
}
if received.Task.Status != model.TaskRunning {
t.Fatalf("received snapshot status = %q, want %q", received.Task.Status, model.TaskRunning)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for snapshot from SubscribeTask")
}
}
func TestSubscribeTask_Cancel(t *testing.T) {
svc := newTestDeliveryService()
taskID := "test-task-cancel"
ch, cancel := svc.SubscribeTask(taskID)
// 调用 cancel
cancel()
// 验证 channel 已关闭
select {
case _, ok := <-ch:
if ok {
t.Fatal("channel should be closed after cancel, but got a value")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for channel close")
}
// 验证已从 streams 中移除
svc.streamMu.Lock()
if subs := svc.streams[taskID]; subs != nil && len(subs) > 0 {
svc.streamMu.Unlock()
t.Fatal("cancel did not remove channel from streams map")
}
svc.streamMu.Unlock()
}
func TestSubscribeTask_MultipleSubscribers(t *testing.T) {
svc := newTestDeliveryService()
taskID := "test-task-multi"
ch1, cancel1 := svc.SubscribeTask(taskID)
defer cancel1()
ch2, cancel2 := svc.SubscribeTask(taskID)
defer cancel2()
ch3, cancel3 := svc.SubscribeTask(taskID)
defer cancel3()
// 验证三个订阅者都已注册
svc.streamMu.Lock()
subs := svc.streams[taskID]
if len(subs) != 3 {
svc.streamMu.Unlock()
t.Fatalf("expected 3 subscribers, got %d", len(subs))
}
svc.streamMu.Unlock()
// 推送 snapshot
snapshot := DeliveryTaskSnapshot{
Task: &model.DeliveryTask{ID: taskID, Status: model.TaskFinished},
}
svc.streamMu.Lock()
for ch := range svc.streams[taskID] {
select {
case ch <- snapshot:
default:
}
}
svc.streamMu.Unlock()
// 验证三个订阅者都收到
for i, ch := range []<-chan DeliveryTaskSnapshot{ch1, ch2, ch3} {
select {
case received := <-ch:
if received.Task == nil || received.Task.Status != model.TaskFinished {
t.Fatalf("subscriber %d: expected TaskFinished, got %+v", i+1, received)
}
case <-time.After(time.Second):
t.Fatalf("subscriber %d: timeout waiting for snapshot", i+1)
}
}
}
func TestSubscribeTask_CancelOneDoesNotAffectOthers(t *testing.T) {
svc := newTestDeliveryService()
taskID := "test-task-cancel-one"
ch1, cancel1 := svc.SubscribeTask(taskID)
_, cancel2 := svc.SubscribeTask(taskID)
_ = cancel2
// 取消第一个订阅者
cancel1()
// 验证还剩一个订阅者
svc.streamMu.Lock()
subs := svc.streams[taskID]
if len(subs) != 1 {
svc.streamMu.Unlock()
t.Fatalf("expected 1 subscriber after cancel, got %d", len(subs))
}
svc.streamMu.Unlock()
// 推送 snapshot
snapshot := DeliveryTaskSnapshot{
Task: &model.DeliveryTask{ID: taskID, Status: model.TaskRunning},
}
svc.streamMu.Lock()
for ch := range svc.streams[taskID] {
select {
case ch <- snapshot:
default:
}
}
svc.streamMu.Unlock()
// ch1 已关闭,不应收到消息
select {
case _, ok := <-ch1:
if ok {
t.Fatal("ch1 should be closed after cancel")
}
case <-time.After(100 * time.Millisecond):
// OK: channel is closed, no value received
}
}
func TestBroadcastTask_ClosedChannelCleanup(t *testing.T) {
svc := newTestDeliveryService()
taskID := "test-task-cleanup"
// 创建一个订阅者并立即关闭
_, cancel := svc.SubscribeTask(taskID)
cancel()
// 等待 cancel 完成
time.Sleep(10 * time.Millisecond)
// 创建一个新的正常订阅者
ch2, cancel2 := svc.SubscribeTask(taskID)
defer cancel2()
// 模拟 broadcastTask 行为(带 recover)
snapshot := DeliveryTaskSnapshot{
Task: &model.DeliveryTask{ID: taskID, Status: model.TaskRunning},
}
svc.streamMu.Lock()
var closedChannels []chan DeliveryTaskSnapshot
for ch := range svc.streams[taskID] {
func() {
defer func() {
if r := recover(); r != nil {
closedChannels = append(closedChannels, ch)
}
}()
select {
case ch <- snapshot:
default:
}
}()
}
for _, ch := range closedChannels {
delete(svc.streams[taskID], ch)
}
if len(svc.streams[taskID]) == 0 {
delete(svc.streams, taskID)
}
svc.streamMu.Unlock()
// 验证 closed channel 被清理
svc.streamMu.Lock()
if subs := svc.streams[taskID]; subs != nil && len(subs) != 1 {
svc.streamMu.Unlock()
t.Fatalf("expected 1 subscriber after cleanup, got %d", len(subs))
}
svc.streamMu.Unlock()
// 正常订阅者应该收到消息
select {
case received := <-ch2:
if received.Task == nil || received.Task.ID != taskID {
t.Fatalf("expected task ID %q, got %+v", taskID, received)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for snapshot from normal subscriber")
}
}
func TestInvalidateStdoutCache(t *testing.T) {
svc := newTestDeliveryService()
// 填充缓存
svc.cacheMu.Lock()
svc.stdoutCache["job-1"] = &stdoutCacheItem{stdout: "cached output", createdAt: time.Now()}
svc.cacheMu.Unlock()
// 验证缓存命中
svc.cacheMu.RLock()
item, ok := svc.stdoutCache["job-1"]
svc.cacheMu.RUnlock()
if !ok {
t.Fatal("cache entry not found before invalidation")
}
if item.stdout != "cached output" {
t.Fatalf("cache stdout = %q, want %q", item.stdout, "cached output")
}
// 失效缓存
svc.InvalidateStdoutCache("job-1")
// 验证缓存已失效
svc.cacheMu.RLock()
_, ok = svc.stdoutCache["job-1"]
svc.cacheMu.RUnlock()
if ok {
t.Fatal("cache entry still exists after invalidation")
}
}
func TestInvalidateStdoutCache_NonExistent(t *testing.T) {
svc := newTestDeliveryService()
// 对不存在的 key 调用 invalidate 不应 panic
svc.InvalidateStdoutCache("non-existent-job")
// 验证缓存为空
svc.cacheMu.RLock()
size := len(svc.stdoutCache)
svc.cacheMu.RUnlock()
if size != 0 {
t.Fatalf("cache size = %d, want 0", size)
}
}
func TestStdoutCache_TTLExpiry(t *testing.T) {
svc := newTestDeliveryService()
// 填充一个已过期的缓存条目
svc.cacheMu.Lock()
svc.stdoutCache["job-expired"] = &stdoutCacheItem{
stdout: "old output",
createdAt: time.Now().Add(-stdoutCacheTTL - time.Second),
}
svc.cacheMu.Unlock()
// 模拟 AWXJobStdout 的缓存检查逻辑
svc.cacheMu.RLock()
cacheHit := false
if item, ok := svc.stdoutCache["job-expired"]; ok {
if time.Since(item.createdAt) < stdoutCacheTTL {
cacheHit = true
}
}
svc.cacheMu.RUnlock()
if cacheHit {
t.Fatal("expired cache entry should not be a hit")
}
}
func TestStdoutCache_FreshEntry(t *testing.T) {
svc := newTestDeliveryService()
// 填充一个新鲜的缓存条目
svc.cacheMu.Lock()
svc.stdoutCache["job-fresh"] = &stdoutCacheItem{
stdout: "fresh output",
createdAt: time.Now(),
}
svc.cacheMu.Unlock()
// 模拟 AWXJobStdout 的缓存检查逻辑
svc.cacheMu.RLock()
cacheHit := false
var cachedStdout string
if item, ok := svc.stdoutCache["job-fresh"]; ok {
if time.Since(item.createdAt) < stdoutCacheTTL {
cacheHit = true
cachedStdout = item.stdout
}
}
svc.cacheMu.RUnlock()
if !cacheHit {
t.Fatal("fresh cache entry should be a hit")
}
if cachedStdout != "fresh output" {
t.Fatalf("cached stdout = %q, want %q", cachedStdout, "fresh output")
}
}
func TestSubscribeTask_ConcurrentSafety(t *testing.T) {
svc := newTestDeliveryService()
taskID := "test-concurrent"
var wg sync.WaitGroup
const goroutines = 50
// 并发订阅
cancels := make([]func(), goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, cancel := svc.SubscribeTask(taskID)
cancels[idx] = cancel
}(i)
}
wg.Wait()
// 验证所有订阅者都已注册
svc.streamMu.Lock()
subs := svc.streams[taskID]
if len(subs) != goroutines {
svc.streamMu.Unlock()
t.Fatalf("expected %d subscribers, got %d", goroutines, len(subs))
}
svc.streamMu.Unlock()
// 并发取消
for _, cancel := range cancels {
wg.Add(1)
go func(c func()) {
defer wg.Done()
c()
}(cancel)
}
wg.Wait()
// 验证所有订阅者都已移除
svc.streamMu.Lock()
if subs := svc.streams[taskID]; subs != nil && len(subs) > 0 {
svc.streamMu.Unlock()
t.Fatalf("expected 0 subscribers after concurrent cancel, got %d", len(subs))
}
svc.streamMu.Unlock()
}
func TestStdoutCache_ConcurrentAccess(t *testing.T) {
svc := newTestDeliveryService()
var wg sync.WaitGroup
const goroutines = 50
// 并发写入缓存
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
jobID := "job-" + string(rune('A'+idx%26))
svc.cacheMu.Lock()
svc.stdoutCache[jobID] = &stdoutCacheItem{
stdout: "output",
createdAt: time.Now(),
}
svc.cacheMu.Unlock()
}(i)
}
// 并发读取缓存
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
jobID := "job-" + string(rune('A'+idx%26))
svc.cacheMu.RLock()
_ = svc.stdoutCache[jobID]
svc.cacheMu.RUnlock()
}(i)
}
// 并发失效缓存
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
jobID := "job-" + string(rune('A'+idx%26))
svc.InvalidateStdoutCache(jobID)
}(i)
}
wg.Wait()
}