* 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 导出方法。
442 lines
13 KiB
Go
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
|
|
}
|