Files
chorus/portal/worker/worker_test.go
T
ilaandClaude Opus 5 52ebca2f62 fix: 修正生成失败错误码被终态码覆盖 (#63)
worker 的终态判定改用随 BeginProviderAttempt 推进的真实尝试计数,
不再使用取任务时的内存快照,避免"已尝试过但无候选可换"被误报为
route_unavailable。

queue 的 Fail() 不再用 JSON_MERGE_PATCH 把终态码写进已结束的尾条
尝试,已完成的尝试记录保持不可变;只有尚未结束的尾条尝试(租约、
快照解码等非 provider 阶段失败)才补写终态信息。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:07:50 +08:00

422 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.
package worker
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"io"
"testing"
"time"
"git.ilapage.cn/OPC/chorus/internal/core/model"
"git.ilapage.cn/OPC/chorus/internal/core/provider"
"git.ilapage.cn/OPC/chorus/internal/core/queue"
"git.ilapage.cn/OPC/chorus/internal/core/router"
corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage"
"git.ilapage.cn/OPC/chorus/internal/platform/ratelimit"
)
type fakeQueue struct {
claim *queue.Claim
inputs []model.GenerationInput
begun []model.Attempt
finished []model.Attempt
succeeded []model.GenerationOutput
failedCode string
deferredAt time.Time
staleSucceed bool
}
func (q *fakeQueue) ClaimNext(context.Context, string, time.Duration) (*queue.Claim, error) {
claim := q.claim
q.claim = nil
return claim, nil
}
func (q *fakeQueue) BeginProviderAttempt(_ context.Context, _ uint64, _ string, memberID, providerModelID uint64) (model.Attempt, bool, error) {
started := time.Now()
attempt := model.Attempt{Type: "provider", RouteMemberID: memberID, ProviderModelID: providerModelID, ProviderOrdinal: uint32(len(q.begun) + 1), StartedAt: &started}
q.begun = append(q.begun, attempt)
return attempt, true, nil
}
func (q *fakeQueue) FinishProviderAttempt(_ context.Context, _ uint64, _ string, attempt model.Attempt) (bool, error) {
q.finished = append(q.finished, attempt)
return true, nil
}
func (q *fakeQueue) Defer(_ context.Context, _ uint64, _ string, availableAt time.Time) (bool, error) {
q.deferredAt = availableAt
return true, nil
}
func (q *fakeQueue) Succeed(_ context.Context, _ uint64, _ string, outputs []model.GenerationOutput, _ model.Attempt) (bool, error) {
if q.staleSucceed {
return false, nil
}
q.succeeded = append(q.succeeded, outputs...)
return true, nil
}
func (q *fakeQueue) Fail(_ context.Context, _ uint64, _ string, code, _ string, _ model.Attempt) (bool, error) {
q.failedCode = code
return true, nil
}
// lastFinishedCode 返回最后一条已结束尝试记录的错误码,用于验证终态码没有覆盖它。
func (q *fakeQueue) lastFinishedCode() string {
if len(q.finished) == 0 {
return ""
}
return q.finished[len(q.finished)-1].ErrorCode
}
func (q *fakeQueue) Inputs(context.Context, uint64) ([]model.GenerationInput, error) {
return q.inputs, nil
}
type fakeController struct{ stopped bool }
func (c *fakeController) StopClaims() { c.stopped = true }
type fakeRuntime struct {
states []router.MemberState
unavailable map[uint64]bool
reservations []router.ReservationRequest
observations []router.CircuitObservation
}
func (r *fakeRuntime) MemberStates(context.Context, model.Capability, []router.MemberSnapshot) ([]router.MemberState, error) {
return append([]router.MemberState(nil), r.states...), nil
}
func (r *fakeRuntime) Reserve(_ context.Context, request router.ReservationRequest) (router.Reservation, error) {
r.reservations = append(r.reservations, request)
if r.unavailable[request.RoutePoolMemberID] {
return router.Reservation{}, router.ErrMemberUnavailable
}
return router.Reservation{RoutePoolMemberID: request.RoutePoolMemberID, State: router.CircuitClosed}, nil
}
func (r *fakeRuntime) Record(_ context.Context, _ router.Reservation, observation router.CircuitObservation) (bool, error) {
r.observations = append(r.observations, observation)
return true, nil
}
type fakeCatalog struct{ selections map[uint64]Selection }
func (c fakeCatalog) Resolve(_ context.Context, member router.MemberSnapshot, _ model.Capability) (Selection, error) {
selection, ok := c.selections[member.RoutePoolMemberID]
if !ok {
return Selection{}, ErrNoProvider
}
return selection, nil
}
type fakeFactory struct {
clients []provider.Client
index int
}
func (f *fakeFactory) New(Selection) (provider.Client, error) {
if f.index >= len(f.clients) {
return nil, errors.New("no fake provider")
}
client := f.clients[f.index]
f.index++
return client, nil
}
type fakeProvider struct {
outputs []provider.Output
err error
}
func (p fakeProvider) Generate(context.Context, provider.Request) ([]provider.Output, error) {
return p.outputs, p.err
}
type fakeStore struct {
deleted []string
}
func (s *fakeStore) Open(context.Context, string) (io.ReadCloser, corestorage.Object, error) {
return nil, corestorage.Object{}, errors.New("unexpected input read")
}
func (s *fakeStore) PutImage(_ context.Context, request ImageRequest) (ImageObjects, error) {
return ImageObjects{
Original: corestorage.Object{Key: request.Key, OwnerID: request.OwnerID, GenerationID: request.GenerationID, ContentType: request.ContentType, Size: 1},
Thumbnail: corestorage.Object{Key: request.ThumbnailKey, OwnerID: request.OwnerID, GenerationID: request.GenerationID, ContentType: "image/png", Size: 1},
}, nil
}
func (s *fakeStore) Delete(_ context.Context, key string) error {
s.deleted = append(s.deleted, key)
return nil
}
type firstRandom struct{}
func (firstRandom) Uint64n(uint64) uint64 { return 0 }
func TestWorkerRetriesOnlyRetryableProviderFailure(t *testing.T) {
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2)}
factory := &fakeFactory{clients: []provider.Client{
fakeProvider{err: &provider.Error{Code: provider.CodeRateLimited, Class: provider.FailureRateLimited}},
fakeProvider{outputs: []provider.Output{{Kind: model.KindText, Text: "done"}}},
}}
w := newWorker(t, q, runtime, fakeCatalog{selections: selections()}, factory, &fakeStore{})
worked, err := w.ProcessOne(context.Background())
if err != nil || !worked || len(q.begun) != 2 || len(q.finished) != 2 || len(q.succeeded) != 1 || q.failedCode != "" {
t.Fatalf("worked=%v err=%v begun=%#v finished=%#v succeeded=%#v failed=%s", worked, err, q.begun, q.finished, q.succeeded, q.failedCode)
}
if len(runtime.observations) != 2 || !runtime.observations[0].Retryable || !runtime.observations[1].Succeeded {
t.Fatalf("runtime observations=%#v", runtime.observations)
}
}
func TestWorkerStopsOnUnauthorizedAndOpensCircuit(t *testing.T) {
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2)}
factory := &fakeFactory{clients: []provider.Client{fakeProvider{err: &provider.Error{Code: provider.CodeUnauthorized, Class: provider.FailureUnauthorized}}}}
w := newWorker(t, q, runtime, fakeCatalog{selections: selections()}, factory, &fakeStore{})
_, err := w.ProcessOne(context.Background())
if err != nil || len(q.begun) != 1 || q.failedCode != string(provider.CodeUnauthorized) {
t.Fatalf("err=%v begun=%#v failed=%s", err, q.begun, q.failedCode)
}
if len(runtime.observations) != 1 || !runtime.observations[0].OpenImmediately || runtime.observations[0].Retryable {
t.Fatalf("runtime observations=%#v", runtime.observations)
}
}
func TestWorkerDefersWhenAllProvidersAreLocallyLimited(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
policy := ratelimit.Policy{Capacity: 1, Window: time.Minute}
limiter := ratelimit.New()
for _, providerID := range []uint64{1, 2} {
if !limiter.Take(now, ratelimit.Bucket{Key: fmt.Sprintf("provider:%d", providerID), Policy: policy}).Allowed {
t.Fatal("failed to fill provider bucket")
}
}
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2)}
w, err := New(Config{
Owner: "rate-test", LeaseDuration: time.Second, PollInterval: time.Millisecond, Random: firstRandom{},
RateLimiter: limiter, ProviderPolicy: policy, Now: func() time.Time { return now },
}, q, &fakeController{}, fakeCatalog{selections: selections()}, &fakeFactory{}, runtime, &fakeStore{})
if err != nil {
t.Fatal(err)
}
worked, err := w.ProcessOne(context.Background())
if err != nil || !worked || !q.deferredAt.Equal(now.Add(time.Minute)) {
t.Fatalf("worked=%v err=%v deferred=%v", worked, err, q.deferredAt)
}
if len(q.begun) != 0 || len(runtime.reservations) != 0 || len(runtime.observations) != 0 || q.failedCode != "" {
t.Fatalf("local limit changed upstream state: begun=%v reservations=%v observations=%v failed=%s", q.begun, runtime.reservations, runtime.observations, q.failedCode)
}
}
func TestWorkerUsesNextProviderWhenFirstIsLocallyLimited(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
policy := ratelimit.Policy{Capacity: 1, Window: time.Minute}
limiter := ratelimit.New()
limiter.Take(now, ratelimit.Bucket{Key: "provider:1", Policy: policy})
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2)}
w, err := New(Config{
Owner: "rate-test", LeaseDuration: time.Second, PollInterval: time.Millisecond, Random: firstRandom{},
RateLimiter: limiter, ProviderPolicy: policy, Now: func() time.Time { return now },
}, q, &fakeController{}, fakeCatalog{selections: selections()}, &fakeFactory{clients: []provider.Client{
fakeProvider{outputs: []provider.Output{{Kind: model.KindText, Text: "done"}}},
}}, runtime, &fakeStore{})
if err != nil {
t.Fatal(err)
}
if worked, err := w.ProcessOne(context.Background()); err != nil || !worked {
t.Fatalf("worked=%v err=%v", worked, err)
}
if len(q.begun) != 1 || q.begun[0].ProviderModelID != 102 || !q.deferredAt.IsZero() || len(q.succeeded) != 1 {
t.Fatalf("begun=%#v deferred=%v succeeded=%#v", q.begun, q.deferredAt, q.succeeded)
}
}
func TestWorkerRefundsProviderTokenWhenCircuitReservationFails(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
policy := ratelimit.Policy{Capacity: 1, Window: time.Minute}
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2), unavailable: map[uint64]bool{1: true}}
selected := selections()
selected[2] = Selection{ProviderID: 1, ProviderModelID: 102, APIType: model.APIChat, ModelID: "second"}
w, err := New(Config{
Owner: "rate-test", LeaseDuration: time.Second, PollInterval: time.Millisecond, Random: firstRandom{},
RateLimiter: ratelimit.New(), ProviderPolicy: policy, Now: func() time.Time { return now },
}, q, &fakeController{}, fakeCatalog{selections: selected}, &fakeFactory{clients: []provider.Client{
fakeProvider{outputs: []provider.Output{{Kind: model.KindText, Text: "done"}}},
}}, runtime, &fakeStore{})
if err != nil {
t.Fatal(err)
}
if worked, err := w.ProcessOne(context.Background()); err != nil || !worked {
t.Fatalf("worked=%v err=%v", worked, err)
}
if len(q.begun) != 1 || q.begun[0].ProviderModelID != 102 || len(q.succeeded) != 1 {
t.Fatalf("provider token was not refunded: begun=%#v succeeded=%#v", q.begun, q.succeeded)
}
}
func TestWorkerCleansStagedOutputAfterStaleSucceed(t *testing.T) {
q := newQueue(t, model.KindImage, model.CapabilityImageGenerate, 0)
q.staleSucceed = true
runtime := &fakeRuntime{states: activeStates(1)}
store := &fakeStore{}
w := newWorker(t, q, runtime, fakeCatalog{selections: map[uint64]Selection{1: {ProviderID: 1, ProviderModelID: 101, APIType: model.APIImages, ModelID: "image"}}}, &fakeFactory{clients: []provider.Client{fakeProvider{outputs: []provider.Output{{Kind: model.KindImage, Content: []byte("image"), ContentType: "image/png"}}}}}, store)
_, err := w.ProcessOne(context.Background())
if err != nil || len(q.succeeded) != 0 || len(store.deleted) != 2 {
t.Fatalf("err=%v succeeded=%#v deleted=%#v", err, q.succeeded, store.deleted)
}
}
func TestWorkerFailsWhenRouteSnapshotIsMissing(t *testing.T) {
q := &fakeQueue{claim: &queue.Claim{Generation: model.Generation{ID: 10, UserID: 1, Kind: model.KindText, Attempts: []byte("[]")}, LeaseToken: "token"}}
w := newWorker(t, q, &fakeRuntime{}, fakeCatalog{}, &fakeFactory{}, &fakeStore{})
_, err := w.ProcessOne(context.Background())
if err != nil || q.failedCode != string(provider.CodeUnknown) {
t.Fatalf("err=%v failed=%s", err, q.failedCode)
}
}
// 单成员池 + 可重试失败:没有候选可换时终态必须是 failover_exhausted,
// 而不是 route_unavailable,且该次尝试自身的 upstream_timeout 必须保留。
func TestWorkerReportsFailoverExhaustedAfterRetryableFailure(t *testing.T) {
q := newQueue(t, model.KindText, model.CapabilityText, 0)
runtime := &fakeRuntime{states: activeStates(1)}
factory := &fakeFactory{clients: []provider.Client{
fakeProvider{err: &provider.Error{Code: provider.CodeTimeout, Class: provider.FailureTimeout}},
}}
w := newWorker(t, q, runtime, fakeCatalog{selections: selections()}, factory, &fakeStore{})
if _, err := w.ProcessOne(context.Background()); err != nil {
t.Fatal(err)
}
if q.failedCode != string(router.CodeFailoverExhausted) {
t.Fatalf("terminal code = %s, want %s", q.failedCode, router.CodeFailoverExhausted)
}
if len(q.begun) != 1 || q.lastFinishedCode() != string(provider.CodeTimeout) {
t.Fatalf("begun=%#v finished=%#v", q.begun, q.finished)
}
if len(runtime.observations) != 1 || !runtime.observations[0].Retryable {
t.Fatalf("runtime observations=%#v", runtime.observations)
}
}
// 两个成员都尝试过且都可重试失败时,同样应报 failover_exhausted。
func TestWorkerReportsFailoverExhaustedAfterAllCandidatesFail(t *testing.T) {
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2)}
factory := &fakeFactory{clients: []provider.Client{
fakeProvider{err: &provider.Error{Code: provider.CodeRateLimited, Class: provider.FailureRateLimited}},
fakeProvider{err: &provider.Error{Code: provider.CodeServerError, Class: provider.FailureServer}},
}}
w := newWorker(t, q, runtime, fakeCatalog{selections: selections()}, factory, &fakeStore{})
if _, err := w.ProcessOne(context.Background()); err != nil {
t.Fatal(err)
}
if q.failedCode != string(router.CodeFailoverExhausted) || len(q.begun) != 2 {
t.Fatalf("terminal code = %s begun=%#v", q.failedCode, q.begun)
}
if q.lastFinishedCode() != string(provider.CodeServerError) {
t.Fatalf("last finished attempt = %#v", q.finished)
}
}
// 真正选不出成员时才是 route_unavailable。
func TestWorkerReportsRouteUnavailableWhenNoMemberIsEligible(t *testing.T) {
q := newQueue(t, model.KindText, model.CapabilityText, 0)
runtime := &fakeRuntime{states: []router.MemberState{{
RoutePoolMemberID: 1, Enabled: false, ProviderEnabled: true,
ModelEnabled: true, SupportsCapability: true, CircuitState: router.CircuitClosed, HalfOpenMax: 1,
}}}
w := newWorker(t, q, runtime, fakeCatalog{selections: selections()}, &fakeFactory{}, &fakeStore{})
if _, err := w.ProcessOne(context.Background()); err != nil {
t.Fatal(err)
}
if q.failedCode != string(router.CodeRouteUnavailable) || len(q.begun) != 0 {
t.Fatalf("terminal code = %s begun=%#v", q.failedCode, q.begun)
}
}
// 不可重试失败仍然直接返回上游错误码,不改成 failover_exhausted。
func TestWorkerKeepsNonRetryableProviderCodeAsTerminal(t *testing.T) {
q := newQueue(t, model.KindText, model.CapabilityText, 1)
runtime := &fakeRuntime{states: activeStates(1, 2)}
factory := &fakeFactory{clients: []provider.Client{
fakeProvider{err: &provider.Error{Code: provider.CodeBadRequest, Class: provider.FailureBadRequest}},
}}
w := newWorker(t, q, runtime, fakeCatalog{selections: selections()}, factory, &fakeStore{})
if _, err := w.ProcessOne(context.Background()); err != nil {
t.Fatal(err)
}
if q.failedCode != string(provider.CodeBadRequest) || len(q.begun) != 1 {
t.Fatalf("terminal code = %s begun=%#v", q.failedCode, q.begun)
}
}
func newQueue(t *testing.T, kind model.GenerationKind, capability model.Capability, maxFailover uint16) *fakeQueue {
t.Helper()
snapshot := router.RouteSnapshot{
Capability: capability, RoutePoolID: 1, RoutePoolVersion: 1, PromptTemplateID: 1,
PromptTemplateKey: "test", PromptTemplateVersion: 1, MaxFailover: maxFailover,
Members: []router.MemberSnapshot{
{RoutePoolMemberID: 1, ProviderModelID: 101, Weight: 1, FailureThreshold: 2, OpenSeconds: 60, HalfOpenMax: 1},
{RoutePoolMemberID: 2, ProviderModelID: 102, Weight: 1, FailureThreshold: 2, OpenSeconds: 60, HalfOpenMax: 1},
},
}
if maxFailover == 0 {
snapshot.Members = snapshot.Members[:1]
}
encoded, err := snapshot.Encode()
if err != nil {
t.Fatal(err)
}
return &fakeQueue{claim: &queue.Claim{Generation: model.Generation{
ID: 10, UserID: 1, Kind: kind, RenderedPrompt: "prompt", RouteSnapshot: encoded,
Attempts: []byte("[]"), AttemptCount: 1,
}, LeaseToken: "token"}}
}
func activeStates(memberIDs ...uint64) []router.MemberState {
states := make([]router.MemberState, 0, len(memberIDs))
for _, memberID := range memberIDs {
states = append(states, router.MemberState{RoutePoolMemberID: memberID, Enabled: true, ProviderEnabled: true, ModelEnabled: true, SupportsCapability: true, CircuitState: router.CircuitClosed, HalfOpenMax: 1})
}
return states
}
func selections() map[uint64]Selection {
return map[uint64]Selection{
1: {ProviderID: 1, ProviderModelID: 101, APIType: model.APIChat, ModelID: "first"},
2: {ProviderID: 2, ProviderModelID: 102, APIType: model.APIChat, ModelID: "second"},
}
}
func newWorker(t *testing.T, q Queue, runtime router.RuntimeRepository, catalog Catalog, factory ClientFactory, store ImageStore) *Worker {
t.Helper()
w, err := New(Config{
Owner: "test", LeaseDuration: time.Second, PollInterval: time.Millisecond, Random: firstRandom{},
RateLimiter: ratelimit.New(), ProviderPolicy: ratelimit.Policy{Capacity: 100, Window: time.Minute},
}, q, &fakeController{}, catalog, factory, runtime, store)
if err != nil {
t.Fatal(err)
}
return w
}
func testPNG() []byte {
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.Set(x, y, color.RGBA{R: 200, G: 80, B: 40, A: 255})
}
}
var output bytes.Buffer
if err := png.Encode(&output, img); err != nil {
panic(err)
}
return output.Bytes()
}