jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:56:07 +08:00

632 lines
18 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")
}
// 確保 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")
}
}