mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
修复 PR #3768 批量图像 MVP 合并后审计报告中的全部问题: 结算与计费(高危): - 所有 SETTLEMENT_* 失败(超冻结/计数非法/manifest 冲突/定价缺失/扣费失败) 统一计入 retry_count 并在耗尽时释放冻结转 failed,消灭 settling 无限 requeue 导致的冻结余额永久锁死 - 耗尽出口的释放指纹统一为 RequestHash,与 processor/Cancel/recovery 一致; release 遇同 request id 指纹冲突视为幂等成功,治愈历史毒消息 - 管理端校验 hold_multiplier >= discount_multiplier,定价快照对存量脏数据钳制 - 释放前校验 per-job hold claim(dedup+归档表),杜绝幻影释放 索引对账(高危): - provider 输出与提交 custom_id 集对账:未知条目丢弃并记事件, 漏项补 PROVIDER_RESULT_MISSING 失败行,保证 success+fail == item_count 提交与恢复(高危): - 提交前转 uploading 并在 provider.Submit 期间心跳刷新 updated_at; 恢复扫描改为原子复核(FailStaleUnsubmittedBatchImageJob), 消灭慢提交被误杀退款而上游任务照常计费的孤儿场景 - 上游任务创建成功但本地状态推进失败时,尽力取消上游并清理输入 - recovery 释放失败时入队交由 worker releaseTerminalHold 兜底重试 队列与并发(中危): - Enqueue(SetNX+LPush)与 Reserve(BRPop+ZAdd)均改为 Lua 原子脚本, 消灭崩溃窗口导致 job 脱离队列、被 7 天 inflight 键锁死 - 锁冲突按 LockConflictDelay 重新入队(原直接丢弃需等 10 分钟 stale 恢复) - 处理期间心跳:active zset 续期(ZAddXX 防幽灵成员)+ 锁 TTL 续期 - ReplaceBatchImageItemsForJob 增加 indexing 状态守卫,防掉队 worker 重写账目 存量回归(中危): - image-only 定价条目(仅图片价无 token 价)恢复 token 计费 fail-closed, 不再按 $0 计费;图片计费路径不受影响 - 鉴权余额门槛恢复 balance <= 0 语义,MinimumBalanceReserve 不再作硬 403 加固: - ZIP max_items 钳制到管理员上限;Submit 补齐 Platform==Gemini 校验; gemini downloadUri 跟随前做 host 白名单校验 - 批量客户端改用共享 httpclient(拨号/TLS/响应头超时有界) - 审计点名的忽略错误(MarkDownloaded/SettlementFailed/AppendEvent 等)改为记日志
287 lines
8.2 KiB
Go
287 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
defaultBatchImageWorkerLockTTL = 5 * time.Minute
|
|
defaultBatchImageWorkerLockConflictDelay = 5 * time.Second
|
|
defaultBatchImageWorkerErrorRetryDelay = time.Minute
|
|
defaultBatchImageWorkerRequeueDelay = 30 * time.Second
|
|
defaultBatchImageWorkerDelayedPollInterval = 5 * time.Second
|
|
defaultBatchImageWorkerRecoveryInterval = 5 * time.Minute
|
|
defaultBatchImageWorkerStaleActiveAfter = 10 * time.Minute
|
|
defaultBatchImageWorkerDelayedMoveLimit = 100
|
|
defaultBatchImageWorkerRecoverLimit = 100
|
|
defaultBatchImageWorkerErrorBackoff = time.Second
|
|
defaultBatchImageWorkerReserveBlockTimeout = 5 * time.Second
|
|
)
|
|
|
|
type BatchImageProcessor interface {
|
|
Process(ctx context.Context, batchID string) (BatchImageProcessResult, error)
|
|
}
|
|
|
|
type BatchImageProcessResult struct {
|
|
RequeueAfter time.Duration
|
|
Terminal bool
|
|
}
|
|
|
|
type BatchImageWorkerOptions struct {
|
|
ReserveBlockTimeout time.Duration
|
|
JobLockTTL time.Duration
|
|
LockConflictDelay time.Duration
|
|
DefaultRequeueDelay time.Duration
|
|
ErrorRetryDelay time.Duration
|
|
ErrorBackoff time.Duration
|
|
DelayedPollInterval time.Duration
|
|
RecoveryInterval time.Duration
|
|
StaleActiveAfter time.Duration
|
|
DelayedMoveLimit int
|
|
RecoverLimit int
|
|
}
|
|
|
|
type BatchImageWorker struct {
|
|
queue BatchImageQueue
|
|
processor BatchImageProcessor
|
|
opts BatchImageWorkerOptions
|
|
}
|
|
|
|
func NewBatchImageWorker(queue BatchImageQueue, processor BatchImageProcessor, opts BatchImageWorkerOptions) *BatchImageWorker {
|
|
return &BatchImageWorker{
|
|
queue: queue,
|
|
processor: processor,
|
|
opts: normalizeBatchImageWorkerOptions(opts),
|
|
}
|
|
}
|
|
|
|
func NewBatchImageWorkerOptionsFromConfig(cfg *config.Config) BatchImageWorkerOptions {
|
|
if cfg == nil {
|
|
return normalizeBatchImageWorkerOptions(BatchImageWorkerOptions{})
|
|
}
|
|
return normalizeBatchImageWorkerOptions(BatchImageWorkerOptions{
|
|
JobLockTTL: time.Duration(cfg.BatchImage.JobLockTTLSeconds) * time.Second,
|
|
LockConflictDelay: time.Duration(cfg.BatchImage.LockConflictDelaySeconds) * time.Second,
|
|
DefaultRequeueDelay: time.Duration(cfg.BatchImage.DefaultRequeueDelaySeconds) * time.Second,
|
|
ErrorRetryDelay: time.Duration(cfg.BatchImage.ErrorRetryDelaySeconds) * time.Second,
|
|
DelayedPollInterval: time.Duration(cfg.BatchImage.DelayedMoverIntervalSeconds) * time.Second,
|
|
RecoveryInterval: time.Duration(cfg.BatchImage.RecoveryIntervalSeconds) * time.Second,
|
|
StaleActiveAfter: time.Duration(cfg.BatchImage.StaleActiveAfterSeconds) * time.Second,
|
|
DelayedMoveLimit: cfg.BatchImage.DelayedMoveLimit,
|
|
RecoverLimit: cfg.BatchImage.RecoverLimit,
|
|
})
|
|
}
|
|
|
|
func normalizeBatchImageWorkerOptions(opts BatchImageWorkerOptions) BatchImageWorkerOptions {
|
|
if opts.ReserveBlockTimeout <= 0 {
|
|
opts.ReserveBlockTimeout = defaultBatchImageWorkerReserveBlockTimeout
|
|
}
|
|
if opts.JobLockTTL <= 0 {
|
|
opts.JobLockTTL = defaultBatchImageWorkerLockTTL
|
|
}
|
|
if opts.LockConflictDelay <= 0 {
|
|
opts.LockConflictDelay = defaultBatchImageWorkerLockConflictDelay
|
|
}
|
|
if opts.DefaultRequeueDelay <= 0 {
|
|
opts.DefaultRequeueDelay = defaultBatchImageWorkerRequeueDelay
|
|
}
|
|
if opts.ErrorRetryDelay <= 0 {
|
|
opts.ErrorRetryDelay = defaultBatchImageWorkerErrorRetryDelay
|
|
}
|
|
if opts.ErrorBackoff <= 0 {
|
|
opts.ErrorBackoff = defaultBatchImageWorkerErrorBackoff
|
|
}
|
|
if opts.DelayedPollInterval <= 0 {
|
|
opts.DelayedPollInterval = defaultBatchImageWorkerDelayedPollInterval
|
|
}
|
|
if opts.RecoveryInterval <= 0 {
|
|
opts.RecoveryInterval = defaultBatchImageWorkerRecoveryInterval
|
|
}
|
|
if opts.StaleActiveAfter <= 0 {
|
|
opts.StaleActiveAfter = defaultBatchImageWorkerStaleActiveAfter
|
|
}
|
|
if opts.DelayedMoveLimit <= 0 {
|
|
opts.DelayedMoveLimit = defaultBatchImageWorkerDelayedMoveLimit
|
|
}
|
|
if opts.RecoverLimit <= 0 {
|
|
opts.RecoverLimit = defaultBatchImageWorkerRecoverLimit
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func (w *BatchImageWorker) Run(ctx context.Context) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return
|
|
}
|
|
if err := w.RunOnce(ctx); err != nil && ctx.Err() == nil {
|
|
sleepOrDone(ctx, w.opts.ErrorBackoff)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *BatchImageWorker) RunOnce(ctx context.Context) error {
|
|
if w == nil || w.queue == nil || w.processor == nil {
|
|
return nil
|
|
}
|
|
|
|
reserved, err := w.queue.Reserve(ctx, w.opts.ReserveBlockTimeout)
|
|
if errors.Is(err, ErrBatchImageQueueEmpty) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
lock, ok, err := w.queue.TryAcquireJobLock(ctx, reserved.BatchID, w.opts.JobLockTTL)
|
|
if err != nil {
|
|
if requeueErr := w.queue.RequeueAfter(ctx, reserved.BatchID, w.opts.LockConflictDelay); requeueErr != nil {
|
|
return requeueErr
|
|
}
|
|
return err
|
|
}
|
|
if !ok {
|
|
// 锁被其他实例持有:按冲突延迟重新入队。直接丢弃会让 job 滞留在
|
|
// active zset,最早要等 StaleActiveAfter 才被恢复,造成分钟级停摆。
|
|
return w.queue.RequeueAfter(ctx, reserved.BatchID, w.opts.LockConflictDelay)
|
|
}
|
|
defer func() {
|
|
_ = lock.Release(ctx)
|
|
}()
|
|
|
|
// 处理期间持续心跳:刷新 active zset 时间戳防止 stale 恢复把在处理的
|
|
// job 重投给其他 worker,并对支持续期的锁实现延长锁 TTL。
|
|
hbStop := make(chan struct{})
|
|
hbDone := make(chan struct{})
|
|
go w.runJobHeartbeat(ctx, reserved.BatchID, lock, hbStop, hbDone)
|
|
|
|
result, err := w.processor.Process(ctx, reserved.BatchID)
|
|
close(hbStop)
|
|
<-hbDone
|
|
if err != nil {
|
|
logger.L().Warn("batch_image.worker_process_failed",
|
|
zap.String("batch_id", reserved.BatchID),
|
|
zap.Error(err),
|
|
)
|
|
return w.queue.RequeueAfter(ctx, reserved.BatchID, w.opts.ErrorRetryDelay)
|
|
}
|
|
if result.Terminal {
|
|
return w.queue.Ack(ctx, reserved.BatchID)
|
|
}
|
|
delay := result.RequeueAfter
|
|
if delay <= 0 {
|
|
delay = w.opts.DefaultRequeueDelay
|
|
}
|
|
return w.queue.RequeueAfter(ctx, reserved.BatchID, delay)
|
|
}
|
|
|
|
// BatchImageJobLockRefresher 是可选的锁续期能力;由具体锁实现按需提供。
|
|
type BatchImageJobLockRefresher interface {
|
|
Refresh(ctx context.Context, ttl time.Duration) error
|
|
}
|
|
|
|
func (w *BatchImageWorker) heartbeatInterval() time.Duration {
|
|
interval := w.opts.JobLockTTL
|
|
if w.opts.StaleActiveAfter < interval {
|
|
interval = w.opts.StaleActiveAfter
|
|
}
|
|
interval /= 3
|
|
if interval < time.Second {
|
|
interval = time.Second
|
|
}
|
|
return interval
|
|
}
|
|
|
|
func (w *BatchImageWorker) runJobHeartbeat(ctx context.Context, batchID string, lock BatchImageJobLock, stop <-chan struct{}, done chan<- struct{}) {
|
|
defer close(done)
|
|
ticker := time.NewTicker(w.heartbeatInterval())
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := w.queue.Heartbeat(ctx, batchID); err != nil && ctx.Err() == nil {
|
|
logger.L().Warn("batch_image.worker_heartbeat_failed",
|
|
zap.String("batch_id", batchID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
if refresher, ok := lock.(BatchImageJobLockRefresher); ok {
|
|
if err := refresher.Refresh(ctx, w.opts.JobLockTTL); err != nil && ctx.Err() == nil {
|
|
logger.L().Warn("batch_image.worker_lock_refresh_failed",
|
|
zap.String("batch_id", batchID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *BatchImageWorker) MoveDueDelayedOnce(ctx context.Context) (int, error) {
|
|
if w == nil || w.queue == nil {
|
|
return 0, nil
|
|
}
|
|
return w.queue.MoveDueDelayedToReady(ctx, w.opts.DelayedMoveLimit)
|
|
}
|
|
|
|
func (w *BatchImageWorker) RunDelayedMover(ctx context.Context) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return
|
|
}
|
|
moved, _ := w.MoveDueDelayedOnce(ctx)
|
|
if moved > 0 {
|
|
continue
|
|
}
|
|
sleepOrDone(ctx, w.opts.DelayedPollInterval)
|
|
}
|
|
}
|
|
|
|
func (w *BatchImageWorker) RecoverStaleActiveOnce(ctx context.Context) (int, error) {
|
|
if w == nil || w.queue == nil {
|
|
return 0, nil
|
|
}
|
|
return w.queue.RecoverStaleActive(ctx, w.opts.StaleActiveAfter, w.opts.RecoverLimit)
|
|
}
|
|
|
|
func (w *BatchImageWorker) RunStaleActiveRecovery(ctx context.Context) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return
|
|
}
|
|
_, _ = w.RecoverStaleActiveOnce(ctx)
|
|
sleepOrDone(ctx, w.opts.RecoveryInterval)
|
|
}
|
|
}
|
|
|
|
func sleepOrDone(ctx context.Context, d time.Duration) {
|
|
if d <= 0 {
|
|
return
|
|
}
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-timer.C:
|
|
}
|
|
}
|