jim800121chen ce6a657df4 feat(visionA-backend): Phase 0.8b v0.6 對齊 — T1+T2 download 改走 converter.GetResult
對齊 ADR-016:visionA download 不再經 FAA delegated token、改用 converter GET /api/v1/jobs/{id}/result 中轉。

T1 — converter_client.go 加 GetResult method:
- 新增 GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)
- 新增 ErrResultExpired sentinel + ErrorCode("result_expired") + HTTPStatus 410 mapping
- 獨立 StreamHTTPClient (無 timeout / dial+response header timeout) 給 streaming 大檔
- doStreamWithRetry / doStreamOnce / mapGetResultError / resultRetryBackoff helpers
- parseFilenameFromContentDisposition (RFC 5987 quoted/unquoted/encoded)
- 9 個 GetResult test + 6 個 parseFilename sub-test
- Reviewer 0 Critical / 0 Major / 3 Minor (M-1/M-2/M-3 全部 T2 順手修)

T2 — flow.go + e2e 改造:
- DownloadStream / PromoteToModels 移除 f.faa.GetFile(...) 改 f.converter.GetResult(ctx, jobID)
- filename 仍由 defaultDownloadFilename(cj) 覆寫 (visionA source-of-truth)
- 8 個 flow_test 既有 test 改寫 + 2 個改名 (FAA → Converter) + 2 個 410 透傳 test 新增
- e2e mock converter 加 GET /api/v1/jobs/{id}/result endpoint + 3 helper + 6 斷言更新 (含 negative: FAA 0 命中 / converter /result ≥1 命中)
- T1 reviewer 3 個 Minor 全處理 (mapGetResultError 設計取捨 godoc / 指數退避→線性退避 / 401+403 mask 驗證)
- 保留 faa FAAClient 欄位 + FlowOpts.FAA 必填 (T3 才砍 faa_client.go 整檔)

T2 修補 (architect + backend 平行):
- M-1 conversion.go Service interface DownloadStream/PromoteToModels godoc 對齊 v0.6 (從 flow.go layer 搬上來)
- M-2 conversion.md v0.6 → v0.6.1 — §2.5 ensurePromoted cache 描述「sync.Map cache」改為「Phase 0.8 簡化 (不實作 cache)」+ 4 簡化理由 + 3 Phase 1+ 升級選項 (in-memory / DB / model store 推論);連動修改 line 169 / 300 / 1187 cross-reference
- 3 Minor + 2 Suggestion 順手做 (resultRetryBaseDelay godoc / fixture 註解過渡狀態 / e2e route table 4→5 / flow.go struct T3 預期清單 / e2e negative assertion 強化)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸 (v0.6-t1-review + v0.6-t2-review + v0.6-t2-fix-review) 全  通過

對齊 ADR-016 §1 / conversion.md v0.6.1 §2.5 §4.1 / api-conversion.md v0.6 §4 / oidc-tdd.md v0.4 §13.1.3

下一步:
- T3 砍 faa_client.go + faa_client_test.go + 對應 ErrFAA* sentinel (B 層強制跑 / s-3/s-4/s-5 必補)
- T4 砍 ConversionConfig FAA* 欄位 + main.go wire 點 + .env*.example
- T5 main.go wire 點全切 + e2e regression 防護

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:09:20 +08:00

638 lines
19 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.

// Ownership store 單元測試。
//
// 測試策略:
// - Set/Get/Delete 用 race detector 驗 concurrent safety
// - EnsureRebuilt 用 stub ConverterClientatomic counter 紀錄 fetch 次數)
// 驗first-call fetches / second-call noop / per-user 並行 / thundering herd 收斂
// - 失敗路徑驗error 不標 rebuilt → 下次再 fetch
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6.1)
package conversion
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ==========================================================================
// stub ConverterClient — 只實作 ListInProgressJobs其他 panic測試不用
// ==========================================================================
// stubConverterClient 是 test 用的 fake ConverterClient。
//
// 只實作 ListInProgressJobs其他 method 測試不用panic 防呆);用 atomic counter
// 紀錄各 user 被呼叫次數。
type stubConverterClient struct {
mu sync.Mutex
// jobsByUser: user_id → 該 user 的 in_progress jobs若 nil → 空 slice
jobsByUser map[string][]*ConverterJob
// errByUser: user_id → 強制回傳的錯誤(用在失敗路徑測試)
errByUser map[string]error
// callCountByUser: user_id → ListInProgressJobs 被呼叫次數atomic counter
callCountByUser sync.Map // map[string]*atomic.Int32
// fetchDelay 模擬慢 fetch讓併發測試有機會競態
fetchDelay time.Duration
// blockSignal 若非 nil每次 ListInProgressJobs 進入時發 signal用在 timeout 測試)
blockSignal chan struct{}
// blockUntil 若非 nil會 block 在 ctx.Done 或這個 channel 任一觸發
blockUntil chan struct{}
}
func newStubConverterClient() *stubConverterClient {
return &stubConverterClient{
jobsByUser: make(map[string][]*ConverterJob),
errByUser: make(map[string]error),
}
}
func (s *stubConverterClient) setJobs(userID string, jobs []*ConverterJob) {
s.mu.Lock()
defer s.mu.Unlock()
s.jobsByUser[userID] = jobs
}
func (s *stubConverterClient) setError(userID string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.errByUser[userID] = err
}
// callCount 取某個 user 被呼叫的次數。
func (s *stubConverterClient) callCount(userID string) int32 {
v, ok := s.callCountByUser.Load(userID)
if !ok {
return 0
}
return v.(*atomic.Int32).Load()
}
func (s *stubConverterClient) ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error) {
// atomic counter
cnt, _ := s.callCountByUser.LoadOrStore(userID, &atomic.Int32{})
cnt.(*atomic.Int32).Add(1)
// 通知 caller 已進入(給 thundering herd 測試用)
if s.blockSignal != nil {
select {
case s.blockSignal <- struct{}{}:
default:
}
}
// 若有 blockUntil等到 signal 或 ctx.Done 才 return模擬慢 / cancel
if s.blockUntil != nil {
select {
case <-s.blockUntil:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if s.fetchDelay > 0 {
select {
case <-time.After(s.fetchDelay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
s.mu.Lock()
err := s.errByUser[userID]
jobs := s.jobsByUser[userID]
s.mu.Unlock()
if err != nil {
return nil, err
}
if jobs == nil {
jobs = []*ConverterJob{}
}
return jobs, nil
}
// 其他 method panic測試不會呼叫撞到 panic 反而好 debug
func (s *stubConverterClient) InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
panic("stubConverterClient.InitJob: not used in ownership_test")
}
func (s *stubConverterClient) GetJob(ctx context.Context, jobID string) (*ConverterJob, error) {
panic("stubConverterClient.GetJob: not used in ownership_test")
}
func (s *stubConverterClient) Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error) {
panic("stubConverterClient.Promote: not used in ownership_test")
}
// GetResult — Phase 0.8b v0.6 新增ADR-016 §1ownership 路徑不會打 GetResult
// 撞到 panic 反而好 debug。
func (s *stubConverterClient) GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
panic("stubConverterClient.GetResult: not used in ownership_test")
}
// 確保 stubConverterClient 滿足 ConverterClient interface編譯期驗
var _ ConverterClient = (*stubConverterClient)(nil)
// ==========================================================================
// helper建立靜默 logger避免測試 stdout 噪音)
// ==========================================================================
func newSilentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// ==========================================================================
// 基本 Set / Get / Delete
// ==========================================================================
// TestSet_Get_Delete_Basicwrite / read / delete 標準操作。
func TestSet_Get_Delete_Basic(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
// Set + Get
o.Set("job-1", "alice")
uid, ok := o.Get("job-1")
assert.True(t, ok)
assert.Equal(t, "alice", uid)
// 覆寫
o.Set("job-1", "bob")
uid, _ = o.Get("job-1")
assert.Equal(t, "bob", uid, "Set 同 jobID 應覆寫")
// Delete
o.Delete("job-1")
_, ok = o.Get("job-1")
assert.False(t, ok, "Delete 後 Get 應回 false")
// 不存在的 jobID
_, ok = o.Get("ghost")
assert.False(t, ok)
// 防呆:空字串不寫入
o.Set("", "alice")
o.Set("job-empty-uid", "")
_, ok = o.Get("")
assert.False(t, ok)
_, ok = o.Get("job-empty-uid")
assert.False(t, ok, "空 userID 不應寫入")
}
// TestDelete_RemovesFromCacheDelete 後 Get 回 false規範必含
func TestDelete_RemovesFromCache(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
o.Set("job-1", "alice")
o.Delete("job-1")
_, ok := o.Get("job-1")
assert.False(t, ok)
// 重複 Delete 不該 panic
o.Delete("job-1")
o.Delete("never-existed")
}
// TestSet_Concurrent100 goroutine 同時 Set 不同 job → race detector 通過。
//
// 規範必含:跑 go test -race -count=3 必綠。
func TestSet_Concurrent(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
const N = 100
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
jobID := "job-" + strconv.Itoa(idx)
userID := "user-" + strconv.Itoa(idx%10) // 10 種 user
o.Set(jobID, userID)
// 立即 Get 驗 not lost
uid, ok := o.Get(jobID)
assert.True(t, ok)
assert.Equal(t, userID, uid)
}(i)
}
wg.Wait()
// 驗 100 個都進去了
for i := 0; i < N; i++ {
jobID := "job-" + strconv.Itoa(i)
_, ok := o.Get(jobID)
assert.True(t, ok)
}
}
// TestSet_Get_Delete_Concurrent_Mixed併發 mixed write/read/deleterace detector 驗。
func TestSet_Get_Delete_Concurrent_Mixed(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
const N = 50
var wg sync.WaitGroup
wg.Add(N * 3)
for i := 0; i < N; i++ {
jobID := "job-" + strconv.Itoa(i)
go func() { defer wg.Done(); o.Set(jobID, "alice") }()
go func() { defer wg.Done(); _, _ = o.Get(jobID) }()
go func() { defer wg.Done(); o.Delete(jobID) }()
}
wg.Wait()
// 不驗結果race 驗 deadlock / 共享 state corruption 即可)
}
// ==========================================================================
// EnsureRebuilt
// ==========================================================================
// TestEnsureRebuilt_FirstCall_Fetches第一次該 user 真的打 converter規範必含
func TestEnsureRebuilt_FirstCall_Fetches(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{
{JobID: "j-1", Status: "running"},
})
o := NewOwnership(stub, newSilentLogger())
err := o.EnsureRebuilt(context.Background(), "alice")
require.NoError(t, err)
assert.Equal(t, int32(1), stub.callCount("alice"), "首次應打 converter 1 次")
// 驗 jobToUser 已寫入
uid, ok := o.Get("j-1")
assert.True(t, ok)
assert.Equal(t, "alice", uid)
}
// TestEnsureRebuilt_SecondCall_NoOp第二次該 user noopatomic counter 驗,規範必含)。
func TestEnsureRebuilt_SecondCall_NoOp(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
o := NewOwnership(stub, newSilentLogger())
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
assert.Equal(t, int32(1), stub.callCount("alice"),
"成功 rebuild 後同 user 後續呼叫應 noop")
}
// TestEnsureRebuilt_DifferentUsers_EachFetch不同 user 各自 fetch 一次(規範必含)。
func TestEnsureRebuilt_DifferentUsers_EachFetch(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{{JobID: "j-a"}})
stub.setJobs("bob", []*ConverterJob{{JobID: "j-b"}})
stub.setJobs("carol", []*ConverterJob{})
o := NewOwnership(stub, newSilentLogger())
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "bob"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "carol"))
assert.Equal(t, int32(1), stub.callCount("alice"))
assert.Equal(t, int32(1), stub.callCount("bob"))
assert.Equal(t, int32(1), stub.callCount("carol"))
// 二次呼叫 noop
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "bob"))
assert.Equal(t, int32(1), stub.callCount("alice"))
assert.Equal(t, int32(1), stub.callCount("bob"))
}
// TestEnsureRebuilt_Concurrent_OnlyOneFetch同 user 100 goroutine 同時 EnsureRebuilt
// → atomic counter 驗只 fetch 一次(規範必含 — thundering herd 收斂)。
func TestEnsureRebuilt_Concurrent_OnlyOneFetch(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
stub.fetchDelay = 50 * time.Millisecond // 故意讓 fetch 慢,放大 race window
o := NewOwnership(stub, newSilentLogger())
const N = 100
var wg sync.WaitGroup
wg.Add(N)
errs := make(chan error, N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
if err := o.EnsureRebuilt(context.Background(), "alice"); err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("EnsureRebuilt 失敗: %v", err)
}
assert.Equal(t, int32(1), stub.callCount("alice"),
"同 user 100 個併發 caller 應只 fetch 1 次DCL 收斂)")
}
// TestEnsureRebuilt_Concurrent_DifferentUsers_NotBlocked不同 user 並行 rebuild
// 互不阻塞per-user mutex 設計驗證)。
func TestEnsureRebuilt_Concurrent_DifferentUsers_NotBlocked(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.fetchDelay = 200 * time.Millisecond
const N = 10
for i := 0; i < N; i++ {
stub.setJobs("u-"+strconv.Itoa(i), []*ConverterJob{})
}
o := NewOwnership(stub, newSilentLogger())
start := time.Now()
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
uid := "u-" + strconv.Itoa(i)
go func() {
defer wg.Done()
_ = o.EnsureRebuilt(context.Background(), uid)
}()
}
wg.Wait()
elapsed := time.Since(start)
// 若 per-user mutex 失效退化成全域鎖N=10 * 200ms = 2s
// 並行情況:應該接近單次 fetch 200ms加上少量 schedule overhead
// 用 1s 當判斷線(給 CI 足夠寬裕)
assert.Less(t, elapsed, time.Second,
"不同 user rebuild 應並行per-user mutexelapsed=%v", elapsed)
}
// TestEnsureRebuilt_ConverterError_NotMarkedRebuiltconverter 5xx → 不標 rebuilt
// → 下次再 fetch規範必含
func TestEnsureRebuilt_ConverterError_NotMarkedRebuilt(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setError("alice", ErrConverterUnavailable)
o := NewOwnership(stub, newSilentLogger())
// 第一次 fetch 失敗
err := o.EnsureRebuilt(context.Background(), "alice")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
assert.Equal(t, int32(1), stub.callCount("alice"))
// 第二次仍會 fetch不標 rebuilt
err = o.EnsureRebuilt(context.Background(), "alice")
require.Error(t, err)
assert.Equal(t, int32(2), stub.callCount("alice"),
"上次失敗後應再次 fetch")
// 第三次成功 → 後續才會 noop
stub.setError("alice", nil)
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
assert.Equal(t, int32(3), stub.callCount("alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
assert.Equal(t, int32(3), stub.callCount("alice"), "成功後才標 rebuilt")
}
// TestEnsureRebuilt_ContextCancelctx cancel 立即 return規範必含
func TestEnsureRebuilt_ContextCancel(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.blockUntil = make(chan struct{}) // 永遠不放 → 強迫等 ctx
stub.setJobs("alice", []*ConverterJob{})
o := NewOwnership(stub, newSilentLogger())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- o.EnsureRebuilt(ctx, "alice")
}()
// 等 50ms 確保 goroutine 已進到 fetchblock 在 blockUntil
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
require.Error(t, err, "ctx cancel 應 return error")
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
"err 應為 context.Canceled 或 DeadlineExceededgot: %v", err,
)
case <-time.After(2 * time.Second):
t.Fatal("ctx cancel 後 EnsureRebuilt 沒有及時 return")
}
// 不標 rebuilt — 下次重試
close(stub.blockUntil) // 解除 block
stub.blockUntil = nil // 後續不再 block
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
}
// TestEnsureRebuilt_Timeoutrebuild 內部 timeoutconverter 慢 > 5s→ return
// timeout error不標 rebuilt。
//
// 為避免測試本身跑 5s+,把 fetchDelay 設 100ms 但用 ctx WithTimeout 50ms 模擬同樣語意:
// 驗 ctx cancel path 即可ownership.go 的 rebuildTimeout 邏輯與此相同)。
func TestEnsureRebuilt_ParentCtxTimeout(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.fetchDelay = 200 * time.Millisecond
stub.setJobs("alice", []*ConverterJob{})
o := NewOwnership(stub, newSilentLogger())
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := o.EnsureRebuilt(ctx, "alice")
require.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded),
"parent ctx timeout 應透傳, got: %v", err)
}
// TestEnsureRebuilt_EmptyUserID空 userID return error。
func TestEnsureRebuilt_EmptyUserID(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
err := o.EnsureRebuilt(context.Background(), "")
require.Error(t, err)
}
// ==========================================================================
// ActiveJobOf
// ==========================================================================
// TestActiveJobOf_AfterRebuildrebuild 後從 jobToUser 反查到 in_progress 的 job_id規範必含
func TestActiveJobOf_AfterRebuild(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{
{JobID: "j-active-1", Status: "running"},
})
o := NewOwnership(stub, newSilentLogger())
// rebuild 前 ActiveJobOf 應空cache 沒資料)
jobs := o.ActiveJobOf("alice")
assert.Len(t, jobs, 0)
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
// rebuild 後反查
jobs = o.ActiveJobOf("alice")
require.Len(t, jobs, 1)
assert.Equal(t, "j-active-1", jobs[0])
}
// TestActiveJobOf_Empty_NoJobsuser 沒任何 job → 空 slice規範必含
func TestActiveJobOf_Empty_NoJobs(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{}) // 沒 active job
o := NewOwnership(stub, newSilentLogger())
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
jobs := o.ActiveJobOf("alice")
assert.NotNil(t, jobs, "回非 nil 空 slice 給 caller 安全 range")
assert.Len(t, jobs, 0)
}
// TestActiveJobOf_OtherUser_NotIncluded反查只回該 user 的,不會混到別 user。
func TestActiveJobOf_OtherUser_NotIncluded(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
o.Set("j-alice", "alice")
o.Set("j-bob", "bob")
o.Set("j-alice-2", "alice")
aliceJobs := o.ActiveJobOf("alice")
assert.ElementsMatch(t, []string{"j-alice", "j-alice-2"}, aliceJobs)
bobJobs := o.ActiveJobOf("bob")
assert.ElementsMatch(t, []string{"j-bob"}, bobJobs)
// 不存在的 user
jobs := o.ActiveJobOf("nobody")
assert.Len(t, jobs, 0)
// 空 user_id
jobs = o.ActiveJobOf("")
assert.Nil(t, jobs)
}
// TestActiveJobOf_AfterDeleteDelete 後反查不回該 job。
func TestActiveJobOf_AfterDelete(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
o.Set("j-1", "alice")
o.Set("j-2", "alice")
assert.Len(t, o.ActiveJobOf("alice"), 2)
o.Delete("j-1")
jobs := o.ActiveJobOf("alice")
require.Len(t, jobs, 1)
assert.Equal(t, "j-2", jobs[0])
}
// ==========================================================================
// 壓力測試 — 全 method 併發 race + 不死鎖
// ==========================================================================
// TestStress_AllMethods_Concurrent所有 method 同時跑race detector 驗 + 完成不 timeout。
func TestStress_AllMethods_Concurrent(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
for i := 0; i < 5; i++ {
uid := "u-" + strconv.Itoa(i)
stub.setJobs(uid, []*ConverterJob{
{JobID: fmt.Sprintf("j-%d-a", i)},
})
}
o := NewOwnership(stub, newSilentLogger())
const ROUNDS = 50
var wg sync.WaitGroup
for i := 0; i < ROUNDS; i++ {
uid := "u-" + strconv.Itoa(i%5)
jobID := "set-" + strconv.Itoa(i)
wg.Add(5)
go func() { defer wg.Done(); o.Set(jobID, uid) }()
go func() { defer wg.Done(); _, _ = o.Get(jobID) }()
go func() { defer wg.Done(); _ = o.EnsureRebuilt(context.Background(), uid) }()
go func() { defer wg.Done(); _ = o.ActiveJobOf(uid) }()
go func() { defer wg.Done(); o.Delete(jobID) }()
}
doneCh := make(chan struct{})
go func() { wg.Wait(); close(doneCh) }()
select {
case <-doneCh:
// ok
case <-time.After(5 * time.Second):
t.Fatal("壓力測試 5s 沒結束 — 疑似 deadlock")
}
}