Files
tbphp 1a6e454475 feat(subscription): 接入 Codex/Claude 被动额度信号观察 (#487)
* feat(subscription): 接入 Codex/Claude 被动额度信号观察

从 Codex/Claude 订阅请求的响应 Header 中安全采集额度信号,归一化为与主动观察一致的账号级窗口,
经内存 pending map 合并后由 RequestLog worker 复用现有 quotaWake 异步落库,
自动刷新账号卡的额度窗口与同步时间;额度仅用于管理面展示,不参与路由、调度或健康判定。

同时修复 recordCredentialObservationFailure / invalidateCredentialObservationAfterReset
整行 Save 覆盖并发写入 snapshot_json 的既有问题,改为按列更新。

* fix(subscription): 修正被动额度合并的两处数据错误

丢弃比已存记录更旧的被动采样:手动刷新在 pending 落库前完成时,
旧采样会让 observed_at_ms 倒退并用旧额度覆盖新值;updated_at_ms
条件更新只能拦截读取之后的写入,拦不住这种时间倒退。

MergeQuotaWindow 不再用空字符串覆盖 Label/LabelKey/Scope/Unit:
这些是快照必填字段,被清空后 ApplyQuotaWindows 会跳过该窗口并
清除管理面低额度提示。

* fix(subscription): 落库确认后清空 pending 窗口缓存

ack 此前只清 dirty 标记,不清 entry.windows;下一次响应即使只带
部分字段,也会把已经落库的陈旧字段当作当前观察重新合并进去,
可能覆盖同一时间发生的主动刷新写入的更新值。
现在版本匹配时一并清空窗口缓存,配合已有的 MergeQuotaWindow
字段级保护,落空字段的响应不会再用零值或陈旧值覆盖数据库现值。

* fix(subscription): 通用化 Codex 被动额度解析并修正三处数据错误

Codex 被动解析改为从 Header 自动发现限额命名空间,不再写死
primary/secondary 两个账号级窗口。带 Limit-Name 的附加限额
(如 GPT 5.3 Codex Spark)现在也能被动更新,且窗口 ID 与主动
JSON 路径一致;将来新增的限额类型无需改代码即可识别。缺少
Limit-Name 的命名空间跳过,因为无法生成可匹配的稳定 ID。

同时修正:
- 越界 used-percent、非正或溢出的 reset/window 一律丢弃,
  不再用 0 或回绕值覆盖快照中的有效字段;
- 被动不再输出 Label/LabelKey,避免只带使用率的响应把 7d 窗口
  的标签改写成 Session,与 window_seconds 自相矛盾;
- 主动成功的 observed_at_ms/updated_at_ms 改用响应完成时间
  (last_attempt_at_ms 仍为尝试开始时间),慢速手动刷新不再被
  刷新期间捕获的被动样本反向覆盖,CAS token 也不再可能不变;
- 窗口值未变化但确实观察到时推进 observed_at_ms,低频账号不再
  长期显示数小时前同步。

* refactor(subscription): pending 只保留最新一次响应,不再跨响应合并

pending 条目此前会把多次响应的窗口字段按 ID 合并,却只记录一个
观察时间。混在一起的字段因此可能带着较晚的时间戳落库,反过来
盖掉这期间提交的主动刷新结果。上一轮给 ack 加清缓存只堵住了
"已落库后残留"这一半,尚未落库前合并的那一半仍然存在。

改为每次记录整体替换:一个 pending 条目只承载一次响应,窗口与
观察时间始终描述同一时刻,这类时序覆盖从根上不再可能,ack 也
不需要再清缓存。被丢弃的旧响应不构成数据丢失——快照中该窗口
保持原值,下一次带该窗口的响应会刷新它。

生产代码净减 19 行。

* fix(subscription): 用 observation_version 做被动写入的并发令牌

时间戳不能充当 CAS 令牌:两次写入可能落在同一毫秒,updated_at_ms
数值不变会让已过期的被动写入通过条件更新,盖掉刚提交的主动刷新。
改用 observation_version(主动成功本就递增),并保留 updated_at_ms
比对以覆盖主动失败与重置这两条只改元数据、不动版本的路径。

同时收敛三处局部合同:
- 绝对 reset 无效时继续尝试同一响应中的相对 reset,不再因一个坏
  Header 丢掉可用值;
- 被动输出不再携带 Scope / Unit / IsPrimary(此前只清了 Label),
  贯彻「自动更新只改额度数值与状态」,Codex 与 Claude 两侧一致;
- pending 深拷贝窗口的指针字段,调用方后续修改不会改变待落库内容。

* fix(subscription): ACK 时移除 pending 条目并让同毫秒判给主动结果

ACK 此前只清 dirty 标记,条目连同克隆的窗口一直留在 map 里。凭据
删除后也不会释放,长期运行且凭据有增删的实例会持续增长。改为版本
匹配时直接移除条目——凭据删除本就走「registry 查不到 → ACK」这条
路径,因此不需要再给删除流程挂额外的清理钩子。

被动样本与行内 observed_at_ms 相同时改为丢弃(原为严格小于才丢)。
被动 Header 先捕获、主动刷新稍后完成,两者截断到同一毫秒时,旧的
被动结果会盖掉权威的主动结果,且此时 CAS 不冲突。相同毫秒判给主动
结果即可,不引入更高精度时间戳。该判断收紧后,落库处基于
alreadyObservedAt 的分支成为死代码,一并删除。

顺带移除无生产调用方的 AckPassiveQuotaObservation 导出方法。
2026-08-28 18:26:00 +08:00

442 lines
13 KiB
Go

package requestlog
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gpt-load/internal/accessquota"
"gpt-load/internal/platform/redact"
"gpt-load/internal/platform/utils"
"gpt-load/internal/storage/models"
"gpt-load/internal/telemetry"
)
const (
queueCapacity = 4096
batchSize = 500
flushDelay = time.Second
warningInterval = time.Minute
)
type batchWriter interface {
WriteBatch(context.Context, []models.RequestLog) error
}
// passiveQuotaFlusher is the narrow, package-local view of
// subscription.CredentialManager's passive quota pending store. RequestLog
// depends on this shape only -- it never imports the subscription package or
// its Provider, Observation, or window models.
type passiveQuotaFlusher interface {
FlushPassiveQuotaObservations(ctx context.Context) (bool, error)
SetPassiveQuotaDirtyNotifier(notifier func())
}
type workerTimer interface {
C() <-chan time.Time
Stop() bool
}
type workerTimerFactory func(time.Duration) workerTimer
var _ telemetry.RequestLogSink = (*Service)(nil)
type Service struct {
db *gorm.DB
queue chan queuedEvent
writer batchWriter
redactor *redact.Redactor
retentionPolicy RetentionPolicyProvider
timerFactory workerTimerFactory
logger *logrus.Logger
now func() time.Time
startErr error
accessQuota *accessquota.Runtime
quotaWriter accessQuotaCheckpointWriter
quotaWake chan struct{}
passiveQuota passiveQuotaFlusher
stateMu sync.Mutex
state lifecycleState
workerCancel context.CancelFunc
workerDone chan struct{}
stopRequested chan struct{}
shutdownDone chan struct{}
shutdownErr error
enqueuedTotal atomic.Uint64
persistedTotal atomic.Uint64
droppedNotRunningTotal atomic.Uint64
droppedQueueFullTotal atomic.Uint64
droppedStoppingTotal atomic.Uint64
droppedPersistFailedTotal atomic.Uint64
droppedShutdownTotal atomic.Uint64
writeFailureTotal atomic.Uint64
accessQuotaCheckpointWriteFailureTotal atomic.Uint64
accessQuotaCheckpointDegraded atomic.Bool
retentionDeleteTotal atomic.Uint64
statsMu sync.Mutex
lastWriteFailureAt time.Time
lastAccessQuotaCheckpointWriteFailureAt time.Time
lastRetentionFailureAt time.Time
warningMu sync.Mutex
lastWarningAt time.Time
passiveQuotaWarningMu sync.Mutex
lastPassiveQuotaWarningAt time.Time
}
func NewService(
db *gorm.DB,
redactor *redact.Redactor,
retentionPolicy RetentionPolicyProvider,
accessQuotas ...*accessquota.Runtime,
) *Service {
service := newService(
&gormBatchWriter{db: db},
redactor,
func(delay time.Duration) workerTimer {
return &realWorkerTimer{timer: time.NewTimer(delay)}
},
)
service.db = db
service.retentionPolicy = retentionPolicy
for _, runtime := range accessQuotas {
if runtime != nil {
service.accessQuota = runtime
service.quotaWriter = &gormAccessQuotaCheckpointWriter{db: db}
service.quotaWake = make(chan struct{}, 1)
runtime.SetDirtyNotifier(service.wakeAccessQuotaCheckpoint)
break
}
}
if db == nil {
service.startErr = fmt.Errorf("request log database is nil")
} else if retentionPolicy == nil {
service.startErr = fmt.Errorf("request log retention policy provider is nil")
}
return service
}
func (service *Service) wakeAccessQuotaCheckpoint() {
if service == nil || service.quotaWake == nil {
return
}
select {
case service.quotaWake <- struct{}{}:
default:
}
}
// SetPassiveQuotaFlusher installs the passive credential-quota observation
// flusher and shares this worker's existing quotaWake with it, so a dirty
// passive observation and a dirty AccessQuota checkpoint wake the same
// worker cycle. Call it before Start; a nil flusher is a no-op.
func (service *Service) SetPassiveQuotaFlusher(flusher passiveQuotaFlusher) {
if service == nil || flusher == nil {
return
}
service.passiveQuota = flusher
if service.quotaWake == nil {
service.quotaWake = make(chan struct{}, 1)
}
flusher.SetPassiveQuotaDirtyNotifier(service.wakeAccessQuotaCheckpoint)
}
func (service *Service) warnPassiveQuotaFlushFailure(cause error) {
if service == nil || cause == nil {
return
}
now := service.now()
service.passiveQuotaWarningMu.Lock()
if !service.lastPassiveQuotaWarningAt.IsZero() && now.Before(service.lastPassiveQuotaWarningAt.Add(warningInterval)) {
service.passiveQuotaWarningMu.Unlock()
return
}
service.lastPassiveQuotaWarningAt = now
service.passiveQuotaWarningMu.Unlock()
utils.LogPlaneBestEffort(
service.logger,
logrus.WarnLevel,
utils.LogPlaneData,
logrus.Fields{
"event": "credential_observation.passive_persist_failed",
"error": cause.Error(),
},
"passive credential quota observation persistence failed",
)
}
func newService(
writer batchWriter,
redactor *redact.Redactor,
timerFactory workerTimerFactory,
) *Service {
if redactor == nil {
redactor = redact.New()
}
return &Service{
queue: make(chan queuedEvent, queueCapacity),
writer: writer,
redactor: redactor,
timerFactory: timerFactory,
logger: logrus.StandardLogger(),
now: time.Now,
state: lifecycleNew,
stopRequested: make(chan struct{}),
}
}
func (service *Service) Start() error {
service.stateMu.Lock()
defer service.stateMu.Unlock()
switch service.state {
case lifecycleRunning:
return ErrAlreadyStarted
case lifecycleStopping, lifecycleStopped:
return ErrNotRestartable
}
if service.startErr != nil {
service.state = lifecycleStopped
return fmt.Errorf("start request log service: %w", service.startErr)
}
if service.writer == nil || service.timerFactory == nil {
service.state = lifecycleStopped
return fmt.Errorf("start request log service: incomplete worker configuration")
}
workerContext, cancel := context.WithCancel(context.Background())
service.workerCancel = cancel
service.workerDone = make(chan struct{})
service.state = lifecycleRunning
go service.runWorker(workerContext, service.workerDone)
if service.accessQuota != nil && len(service.accessQuota.DirtySnapshots(1)) > 0 {
service.wakeAccessQuotaCheckpoint()
}
return nil
}
func (service *Service) Emit(event telemetry.RequestEvent) {
service.stateMu.Lock()
switch service.state {
case lifecycleNew:
service.droppedNotRunningTotal.Add(1)
service.stateMu.Unlock()
return
case lifecycleStopping, lifecycleStopped:
service.droppedStoppingTotal.Add(1)
service.stateMu.Unlock()
return
}
cloned := cloneEvent(event)
queueFull := false
select {
case service.queue <- queuedEvent{Event: cloned}:
service.enqueuedTotal.Add(1)
default:
service.droppedQueueFullTotal.Add(1)
queueFull = true
}
service.stateMu.Unlock()
if queueFull {
service.warn("queue_full", 0)
}
service.logCompletedRequest(cloned)
}
func (service *Service) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
service.stateMu.Lock()
switch service.state {
case lifecycleNew:
service.state = lifecycleStopped
service.shutdownDone = make(chan struct{})
close(service.shutdownDone)
service.stateMu.Unlock()
return nil
case lifecycleRunning:
service.state = lifecycleStopping
close(service.stopRequested)
service.shutdownDone = make(chan struct{})
shutdownDone := service.shutdownDone
workerDone := service.workerDone
service.stateMu.Unlock()
go service.finishShutdown(workerDone, shutdownDone)
return service.waitForShutdown(ctx, shutdownDone)
case lifecycleStopping:
shutdownDone := service.shutdownDone
service.stateMu.Unlock()
return service.waitForShutdown(ctx, shutdownDone)
case lifecycleStopped:
err := service.shutdownErr
service.stateMu.Unlock()
return err
default:
service.stateMu.Unlock()
return nil
}
}
func (service *Service) Stats() Stats {
service.statsMu.Lock()
lastWriteFailureAt := service.lastWriteFailureAt
lastAccessQuotaCheckpointWriteFailureAt := service.lastAccessQuotaCheckpointWriteFailureAt
lastRetentionFailureAt := service.lastRetentionFailureAt
service.statsMu.Unlock()
stats := Stats{
EnqueuedTotal: service.enqueuedTotal.Load(),
PersistedTotal: service.persistedTotal.Load(),
DroppedNotRunningTotal: service.droppedNotRunningTotal.Load(),
DroppedQueueFullTotal: service.droppedQueueFullTotal.Load(),
DroppedStoppingTotal: service.droppedStoppingTotal.Load(),
DroppedPersistFailedTotal: service.droppedPersistFailedTotal.Load(),
DroppedShutdownTotal: service.droppedShutdownTotal.Load(),
WriteFailureTotal: service.writeFailureTotal.Load(),
AccessQuotaCheckpointWriteFailureTotal: service.accessQuotaCheckpointWriteFailureTotal.Load(),
AccessQuotaCheckpointDegraded: service.accessQuotaCheckpointDegraded.Load(),
RetentionDeleteFailureTotal: service.retentionDeleteTotal.Load(),
QueueDepth: len(service.queue),
QueueCapacity: cap(service.queue),
LastWriteFailureAt: lastWriteFailureAt,
LastAccessQuotaCheckpointWriteFailureAt: lastAccessQuotaCheckpointWriteFailureAt,
LastRetentionFailureAt: lastRetentionFailureAt,
}
stats.DroppedTotal = stats.DroppedNotRunningTotal +
stats.DroppedQueueFullTotal +
stats.DroppedStoppingTotal +
stats.DroppedPersistFailedTotal +
stats.DroppedShutdownTotal
return stats
}
func (service *Service) waitForShutdown(ctx context.Context, shutdownDone <-chan struct{}) error {
select {
case <-shutdownDone:
return service.shutdownResult()
default:
}
select {
case <-shutdownDone:
return service.shutdownResult()
case <-ctx.Done():
select {
case <-shutdownDone:
return service.shutdownResult()
default:
return service.cancelShutdown(ctx.Err())
}
}
}
func (service *Service) cancelShutdown(cause error) error {
service.stateMu.Lock()
if service.state != lifecycleStopping {
err := service.shutdownErr
service.stateMu.Unlock()
return err
}
if service.shutdownErr == nil {
service.shutdownErr = fmt.Errorf("stop request log service: %w", cause)
cancel := service.workerCancel
err := service.shutdownErr
service.stateMu.Unlock()
cancel()
return err
}
err := service.shutdownErr
service.stateMu.Unlock()
return err
}
func (service *Service) finishShutdown(workerDone <-chan struct{}, shutdownDone chan struct{}) {
<-workerDone
service.stateMu.Lock()
service.state = lifecycleStopped
close(shutdownDone)
service.stateMu.Unlock()
}
func (service *Service) shutdownResult() error {
service.stateMu.Lock()
defer service.stateMu.Unlock()
return service.shutdownErr
}
func (service *Service) recordFinalQuotaCheckpointFailure(cause error) {
if service == nil || cause == nil {
return
}
service.stateMu.Lock()
defer service.stateMu.Unlock()
if service.shutdownErr == nil {
service.shutdownErr = fmt.Errorf(
"stop request log service: final access key cost limit checkpoint: %w",
cause,
)
}
}
func (service *Service) warn(failureType string, failedBatchSize int) {
now := service.now()
service.warningMu.Lock()
if !service.lastWarningAt.IsZero() && now.Before(service.lastWarningAt.Add(warningInterval)) {
service.warningMu.Unlock()
return
}
service.lastWarningAt = now
service.warningMu.Unlock()
stats := service.Stats()
message := "Request log event loss"
if failureType == "access_quota_checkpoint_write_failure" {
message = "Access key cost limit checkpoint persistence failed"
}
utils.LogPlaneBestEffort(
service.logger,
logrus.WarnLevel,
utils.LogPlaneData,
logrus.Fields{
"failure_type": failureType,
"batch_size": failedBatchSize,
"queue_depth": stats.QueueDepth,
"dropped_total": stats.DroppedTotal,
"write_failure_total": stats.WriteFailureTotal,
"access_quota_checkpoint_write_failure_total": stats.AccessQuotaCheckpointWriteFailureTotal,
},
message,
)
}
func cloneEvent(event telemetry.RequestEvent) telemetry.RequestEvent {
cloned := event
if event.FirstResponseMs != nil {
value := *event.FirstResponseMs
cloned.FirstResponseMs = &value
}
cloned.Reasoning = event.Reasoning.Clone()
if event.Attempts != nil {
cloned.Attempts = append([]telemetry.Attempt(nil), event.Attempts...)
for index := range cloned.Attempts {
cloned.Attempts[index].Reasoning = event.Attempts[index].Reasoning.Clone()
}
}
return cloned
}