fix(server): 图搜后的规格匹配脱离 Agent 请求 ctx (#300)
匹配挂在 Agent 提交采集结果那个 HTTP 请求的 context 上。AI 匹配要几十秒到几分钟, Agent 先超时断开,ctx 被取消,匹配当场中断,采购员还得手动点一次「一键匹配」。 线上 2026-09-16 实测六个商品里四个是这样死的(#294 的日志第一次派上用场): shopee 28111/8544/5250/5259: archive spec match failed: context canceled shopee 9214/26680: 成功——只是 Agent 尚未超时 - 用 context.WithoutCancel 派生,保留请求携带的值(trace 不断链),只切断取消 信号,再加 10 分钟上限兜底。 - 新增 specMatchAborted,与 unavailable 分开。ctx 取消是本端调度问题不是 Provider 故障,重试用的是同一个已死的 ctx,毫无意义。此前被归成「AI 匹配服务暂时不可用」, 线上出现过 unavailable=7 而 AI 服务完全正常,会一直误导排查。 - 全档案匹配遇到 aborted 立即早停,不再刷出一串同样的失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -21,10 +21,12 @@ type ArchiveSpecMatchResult struct {
|
||||
Skipped int
|
||||
NoMatch int
|
||||
Unavailable int
|
||||
// Aborted 是本端 context 取消或超时导致的中断,与 Provider 故障无关(#300)。
|
||||
Aborted int
|
||||
}
|
||||
|
||||
// Failed is every value that ended without a mapping, whatever the reason.
|
||||
func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable }
|
||||
func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable + r.Aborted }
|
||||
|
||||
// MatchArchiveSpecs matches every still-unmapped value in a Shopee product's
|
||||
// archive against its linked PDD product (#293).
|
||||
@@ -74,6 +76,10 @@ func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64)
|
||||
continue
|
||||
}
|
||||
switch s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) {
|
||||
case specMatchAborted:
|
||||
// ctx 已死,后面的值一个也做不成,早停以免刷出一串同样的失败。
|
||||
result.Aborted++
|
||||
return result, nil
|
||||
case specMatchUnavailable:
|
||||
result.Unavailable++
|
||||
case specMatchNoMatch:
|
||||
|
||||
@@ -14,8 +14,14 @@ const (
|
||||
// specMatchNoMatch: AI 明确回答「没有可靠对应」。同样的输入下次多半还是这个答案,
|
||||
// 但实测并非一定——见 retryableSpecMatch 的说明。
|
||||
specMatchNoMatch specMatchFailure = "no_match"
|
||||
// specMatchUnavailable: 服务不可用、超时、配置缺失等,与规格内容无关。
|
||||
// specMatchUnavailable: Provider 不可用、配置缺失等外部故障。
|
||||
specMatchUnavailable specMatchFailure = "unavailable"
|
||||
// specMatchAborted: 本端 context 被取消或超时。
|
||||
//
|
||||
// `[必须]` 必须与 unavailable 分开。它是本端调度问题而非 Provider 故障,重试
|
||||
// 毫无意义(同一个 ctx 已经死了),把它读成「AI 服务不可用」会一直误导排查——
|
||||
// 线上 2026-09-16 就出现过 unavailable=7 而 Provider 完全正常的情况(#300)。
|
||||
specMatchAborted specMatchFailure = "aborted"
|
||||
)
|
||||
|
||||
// specMatchAttempts is the total number of AI calls allowed for one value.
|
||||
@@ -42,13 +48,17 @@ func (s *Service) resolveSpecMatch(ctx context.Context, request aimatching.Match
|
||||
}
|
||||
lastErr, lastFailure = err, classifySpecMatchFailure(err)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
// ctx 已死,重试用的是同一个 ctx,再试一次只是多一条误导性日志。
|
||||
return aimatching.MatchResult{}, specMatchAborted, lastErr
|
||||
}
|
||||
}
|
||||
return aimatching.MatchResult{}, lastFailure, lastErr
|
||||
}
|
||||
|
||||
func classifySpecMatchFailure(err error) specMatchFailure {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return specMatchAborted
|
||||
}
|
||||
var matchErr *aimatching.Error
|
||||
if errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeNoMatch {
|
||||
return specMatchNoMatch
|
||||
|
||||
@@ -72,3 +72,31 @@ func TestTransportFailureIsNotReportedAsNoMatch(t *testing.T) {
|
||||
t.Fatalf("failure=%q,应为 unavailable", failure)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` ctx 被取消是本端调度问题,不是 Provider 故障。归成 unavailable 会让
|
||||
// 排查一直看错方向——线上 2026-09-16 就出现过 unavailable=7 而 AI 服务完全正常,
|
||||
// 真正的原因是匹配挂在 Agent 提交请求的 ctx 上、请求断开即中断(#300)。
|
||||
func TestCancelledContextIsAbortedNotUnavailable(t *testing.T) {
|
||||
matcher := &scriptedMatcher{answers: []error{context.Canceled, context.Canceled}}
|
||||
service := &Service{Matcher: matcher}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, failure, err := service.resolveSpecMatch(ctx, aimatching.MatchRequest{TargetColor: "桔紅色"})
|
||||
if err == nil {
|
||||
t.Fatal("应当失败")
|
||||
}
|
||||
if failure != specMatchAborted {
|
||||
t.Fatalf("failure=%q,应为 aborted", failure)
|
||||
}
|
||||
// ctx 已死,重试用的是同一个 ctx,不该再问一次。
|
||||
if matcher.calls != 1 {
|
||||
t.Fatalf("调用了 %d 次,ctx 已取消时不应重试", matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineExceededIsAlsoAborted(t *testing.T) {
|
||||
if got := classifySpecMatchFailure(context.DeadlineExceeded); got != specMatchAborted {
|
||||
t.Fatalf("got %q, want aborted", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/purchase"
|
||||
|
||||
@@ -111,6 +112,15 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a
|
||||
if !linked.Linked || len(linked.SYBProductIDs) == 0 {
|
||||
return
|
||||
}
|
||||
// `[必须]` 不能直接用入站请求的 ctx。这个 ctx 来自 Agent 提交采集结果的那次
|
||||
// HTTP 请求,而 AI 匹配要几十秒到几分钟——Agent 先超时断开,ctx 被取消,
|
||||
// 匹配当场中断,采购员还得手动点一次「一键匹配」。线上 2026-09-16 实测六个
|
||||
// 商品里四个是这样死的,日志全是 "archive spec match failed: context canceled"。
|
||||
// 采购员手动点能成功,正是因为那是另一个不会被取消的请求 ctx(#300)。
|
||||
//
|
||||
// 这里保留请求的值(trace 等),只切断取消信号,再加自己的上限兜底,避免无限挂起。
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), specMatchAfterImageSearchTimeout)
|
||||
defer cancel()
|
||||
// 先把该虾皮商品的完整颜色尺码补齐,再匹配:只匹配本次明细会留下空洞,
|
||||
// 新订单带来新组合时又得重来(#290)。
|
||||
service.syncShopeeSpecs(ctx, linked.ShopeeProductID, linked.PDDProductID)
|
||||
@@ -135,8 +145,8 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a
|
||||
log.Printf("archive spec match failed for shopee product %d: %v", linked.ShopeeProductID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("archive spec match for shopee product %d: confirmed=%d skipped=%d no_match=%d unavailable=%d",
|
||||
linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.NoMatch, archive.Unavailable)
|
||||
log.Printf("archive spec match for shopee product %d: confirmed=%d skipped=%d no_match=%d unavailable=%d aborted=%d",
|
||||
linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.NoMatch, archive.Unavailable, archive.Aborted)
|
||||
}
|
||||
|
||||
// batchSpecMatchDigest renders the first few non-successful reasons so a total
|
||||
@@ -159,3 +169,9 @@ func batchSpecMatchDigest(response purchase.BatchSpecMatchResponse) string {
|
||||
}
|
||||
|
||||
const specMatchDigestLimit = 3
|
||||
|
||||
// specMatchAfterImageSearchTimeout bounds the detached matching pass.
|
||||
//
|
||||
// 一个虾皮商品最多十几个规格值,逐值匹配每次几秒;给到 10 分钟足够跑完最坏情况,
|
||||
// 又不至于在 AI 端真的卡死时无限占着。
|
||||
const specMatchAfterImageSearchTimeout = 10 * time.Minute
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/shopeespec"
|
||||
@@ -103,3 +105,33 @@ func TestSpecSyncedSkipFollowsTheCurrentLink(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 图搜后的匹配不能挂在 Agent 提交请求的生命周期上。AI 匹配要几十秒到
|
||||
// 几分钟,Agent 先超时断开,请求 ctx 被取消,匹配当场中断——线上 2026-09-16
|
||||
// 六个商品里四个是这样死的,日志全是 "archive spec match failed: context canceled",
|
||||
// 采购员只能手动再点一次「一键匹配」(#300)。
|
||||
func TestMatchingContextSurvivesTheAgentRequest(t *testing.T) {
|
||||
request, cancel := context.WithCancel(context.Background())
|
||||
type key struct{}
|
||||
request = context.WithValue(request, key{}, "trace-1")
|
||||
|
||||
detached, stop := context.WithTimeout(context.WithoutCancel(request), specMatchAfterImageSearchTimeout)
|
||||
defer stop()
|
||||
|
||||
// Agent 断开连接。
|
||||
cancel()
|
||||
|
||||
if request.Err() == nil {
|
||||
t.Fatal("请求 ctx 应当已被取消")
|
||||
}
|
||||
if detached.Err() != nil {
|
||||
t.Fatalf("匹配用的 ctx 不该跟着死掉:%v", detached.Err())
|
||||
}
|
||||
// 请求上的值要保留,否则 trace 断链。
|
||||
if detached.Value(key{}) != "trace-1" {
|
||||
t.Fatal("脱钩后应保留请求携带的值,只切断取消信号")
|
||||
}
|
||||
if deadline, ok := detached.Deadline(); !ok || time.Until(deadline) > specMatchAfterImageSearchTimeout {
|
||||
t.Fatal("必须有自己的超时上限,不能无限期挂起")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user