fix(billing): 封住发布前计费竞态
ci / verify (pull_request) Failing after 8s

阻止上游提交状态不明的任务被租约接管后重复执行,并为人工复核保留可操作的结算记录。

将生产提交绑定到当前估价签名,统一复用预处理快照,并补强规则形状、定点溢出与历史规则兼容校验。

已通过 PostgreSQL 16 集成测试、Go 全量测试与静态检查、前端测试与构建、OpenAPI、依赖审计、镜像、迁移、流水线和 SemVer 门禁。
This commit is contained in:
2026-07-21 10:23:58 +08:00
parent 257ee09e58
commit 8beb8501fa
18 changed files with 762 additions and 75 deletions
+52 -8
View File
@@ -302,6 +302,7 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
}
}
pricingByCandidate := map[string]resolvedPricing{}
preprocessingByCandidate := map[string]parameterPreprocessResult{}
reservationBillings := []any(nil)
reservationPricingSnapshot := map[string]any(nil)
if task.RunMode == "production" {
@@ -316,9 +317,17 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
}
estimates := make([]candidateEstimate, 0, len(candidates))
var pricingErr error
var preprocessingErr error
var preprocessingCandidate store.RuntimeModelCandidate
for _, candidate := range candidates {
pricingBody := preprocessRequest(task.Kind, cloneMap(body), candidate)
estimate, estimateErr := s.estimateCandidateV2(ctx, user, task.Kind, pricingBody, candidate)
preprocessing := s.preprocessRequestWithScripts(ctx, task.Kind, body, candidate)
preprocessingByCandidate[pricingCandidateKey(candidate)] = preprocessing
if preprocessing.Err != nil {
preprocessingErr = parameterPreprocessClientError(preprocessing.Err)
preprocessingCandidate = candidate
break
}
estimate, estimateErr := s.estimateCandidateV2(ctx, user, task.Kind, preprocessing.Body, candidate)
if estimateErr != nil {
pricingErr = estimateErr
if billingMode == "enforce" {
@@ -329,8 +338,23 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
estimates = append(estimates, estimate)
pricingByCandidate[pricingCandidateKey(candidate)] = estimate.Pricing
}
if preprocessingErr != nil {
preprocessing := preprocessingByCandidate[pricingCandidateKey(preprocessingCandidate)]
s.recordFailedAttempt(ctx, failedAttemptRecord{
Task: task, Body: preprocessing.Body, Candidate: &preprocessingCandidate,
AttemptNo: task.AttemptCount + 1, Code: clients.ErrorCode(preprocessingErr), Cause: preprocessingErr,
Simulated: false, Scope: "parameter_preprocessing", Reason: "parameter_preprocessing_failed",
ExtraMetrics: []map[string]any{parameterPreprocessingMetrics(preprocessing.Log)}, Preprocessing: &preprocessing.Log,
ModelType: preprocessingCandidate.ModelType,
})
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, clients.ErrorCode(preprocessingErr), preprocessingErr.Error(), false, preprocessingErr, parameterPreprocessingMetrics(preprocessing.Log))
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, preprocessingErr
}
if billingMode == "observe" {
legacyItems, legacyAmount := s.maximumLegacyCandidateEstimate(ctx, user, task.Kind, body, candidates)
legacyItems, legacyAmount := s.maximumLegacyCandidateEstimate(ctx, user, task.Kind, body, candidates, preprocessingByCandidate)
reservationBillings = legacyItems
candidateSnapshots := make([]any, 0, len(estimates))
for _, estimate := range estimates {
@@ -396,7 +420,10 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
}
}()
if len(candidates) > 0 {
preprocessing := s.preprocessRequestWithScripts(ctx, task.Kind, body, candidates[0])
preprocessing, ok := preprocessingByCandidate[pricingCandidateKey(candidates[0])]
if !ok {
preprocessing = s.preprocessRequestWithScripts(ctx, task.Kind, body, candidates[0])
}
firstCandidateBody = preprocessing.Body
firstPreprocessing = preprocessing.Log
normalizedModelType = candidates[0].ModelType
@@ -472,7 +499,10 @@ candidatesLoop:
var candidateErr error
for clientAttempt := 1; clientAttempt <= clientAttempts; clientAttempt++ {
nextAttemptNo := attemptNo + 1
preprocessing := s.preprocessRequestWithScripts(ctx, task.Kind, body, candidate)
preprocessing, ok := preprocessingByCandidate[pricingCandidateKey(candidate)]
if !ok {
preprocessing = s.preprocessRequestWithScripts(ctx, task.Kind, body, candidate)
}
preprocessingLog := preprocessing.Log
lastPreprocessing = &preprocessingLog
if preprocessing.Err != nil {
@@ -768,11 +798,14 @@ func normalizedBillingEngineMode(value string) string {
}
}
func (s *Service) maximumLegacyCandidateEstimate(ctx context.Context, user *auth.User, kind string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]any, fixedAmount) {
func (s *Service) maximumLegacyCandidateEstimate(ctx context.Context, user *auth.User, kind string, body map[string]any, candidates []store.RuntimeModelCandidate, preprocessingByCandidate map[string]parameterPreprocessResult) ([]any, fixedAmount) {
var maximumItems []any
maximumAmount := fixedAmount(0)
for index, candidate := range candidates {
candidateBody := preprocessRequest(kind, cloneMap(body), candidate)
if preprocessing, ok := preprocessingByCandidate[pricingCandidateKey(candidate)]; ok && preprocessing.Err == nil {
candidateBody = preprocessing.Body
}
items := s.estimatedBillings(ctx, user, kind, candidateBody, candidate)
amount := billingItemsFixedTotal(items)
if index == 0 || amount > maximumAmount {
@@ -792,7 +825,11 @@ func billingItemsFixedTotal(items []any) fixedAmount {
}
amount, err := fixedAmountFromAny(line["amount"])
if err == nil && amount > 0 {
total = total.Add(amount)
next, addErr := addFixedAmounts(total, amount)
if addErr != nil {
return maxFixedAmount
}
total = next
}
}
return total
@@ -925,6 +962,11 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
PreviousResponseTurns: responseExecution.PreviousTurns,
})
callFinishedAt := time.Now()
if err == nil {
if markErr := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received"); markErr != nil {
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: markErr}
}
}
if response.ResponseStartedAt.IsZero() {
response.ResponseStartedAt = callStartedAt
}
@@ -939,7 +981,9 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}
if err != nil {
if clients.ErrorResponseMetadata(err).StatusCode > 0 {
_ = s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received")
if markErr := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received"); markErr != nil {
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: markErr}
}
}
retryable := clients.IsRetryable(err)
requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(err, simulated)