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:
QiuSW
2026-09-16 17:20:11 +08:00
co-authored by Claude Opus 5
parent 8f337d5248
commit 87a532f3bb
5 changed files with 97 additions and 5 deletions
@@ -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:
+12 -2
View File
@@ -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)
}
}
+18 -2
View File
@@ -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
+32
View File
@@ -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("必须有自己的超时上限,不能无限期挂起")
}
}