【Bug已解决】Feature request: Auto-resume paused Goals when quota refreshes 解决方案

【Bug已解决】Feature request: Auto-resume paused Goals when quota refreshes 解决方案

【Bug已解决】Feature request: Auto-resume paused Goals when quota refreshes 解决方案

原始报错线索:Feature request: Auto-resume paused Goals when quota refreshes(希望「用量配额刷新后,自动把被暂停的目标/任务恢复运行」,而不是让用户手动去点)。


一、现象长什么样

用户跑一个长任务(目标 / Goal),中途配额耗尽,任务被自动暂停。之后:

  1. 配额在凌晨刷新了,但任务一直停着,没人通知、不自动恢复;
  2. 用户手动点「恢复」,又能跑了——说明任务本身没问题,只是「恢复动作」缺失;
  3. 有时用户自己点了恢复,结果配额刷新检测又触发一次恢复,任务被重复启动两次,互相打架;
  4. 恢复后没记录「已恢复」,配额再波动时又反复暂停/恢复,状态抖动。 这本质是**「配额状态」与「任务调度状态」两个状态机没有联动**,且恢复动作不具备幂等性。

二、背景:配额、暂停与恢复的状态模型

2.1 配额(Quota)的常见形态

  • 周期性配额:每天 / 每小时重置(如「每日 100 次调用」);
  • 容量配额:总量固定,消耗后需手动/自动补充;
  • 速率配额:单位时间令牌数(令牌桶),自然回满。 无论哪种,关键是有一个「当前可用量」随时间变化的量。

2.2 任务的四种状态

RUNNING --配额耗尽--> PAUSED PAUSED --配额恢复--> RUNNING PAUSED --用户取消--> CANCELLED RUNNING --完成------> DONE

「自动恢复」要做的,就是监听「配额从不足变足」,并把PAUSED推回RUNNING

2.3 为什么需要幂等

配额可能在「刚好够 / 刚好不够」之间反复横跳,检测逻辑可能一秒内触发多次「恢复」。若恢复动作不幂等,就会拉起多个执行实例,造成重复工作、双倍扣费、状态错乱。

三、为什么不会自动恢复:根因

3.1 没有「配额变化 → 任务状态」的联动

任务调度器和配额管理器是两套独立代码,配额刷新时只更新了计数器,没通知调度器去扫PAUSED任务。

3.2 轮询太粗或太细

  • 太粗(每小时一次):配额刷新后最长要等一小时才恢复,体验差;
  • 太细(每秒一次):空转浪费资源。需要根据配额重置周期自适应。

3.3 恢复不幂等

多次触发恢复 → 多个实例。缺少「同一任务同一时刻只有一个 RUNNING」的约束。

3.4 抖动(flapping)

配额只在边界上下波动时,任务在 PAUSED/RUNNING 间反复跳。需要加「恢复需配额超过阈值并稳定一段时间」的滞回(hysteresis)。

四、最小可运行复现(不幂等导致重复恢复)

下面演示「恢复动作无幂等保护」时,多次检测触发双实例:

class SchedulerNaive: def __init__(self): self.task_state = "PAUSED" def on_quota_refreshed(self): # 没有幂等检查:只要检测到配额足,就启动一次 if self.task_state == "PAUSED": self.task_state = "RUNNING" print("启动任务实例") # 若被调用两次 -> 两个实例 if __name__ == "__main__": s = SchedulerNaive() # 配额检测在一次刷新中被触发两次(例如两个定时器叠加) s.on_quota_refreshed() s.on_quota_refreshed() # 第二次仍会打印“启动任务实例”

运行会打印两次「启动任务实例」——这就是重复恢复的 bug。

五、解决方案一:配额状态机 + 滞回

用「滞回阈值」避免边界抖动,且只有真正跨越阈值才改变判定:

class Quota: def __init__(self, used, limit): self.used = used self.limit = limit @property def available(self): return self.limit - self.used def is_sufficient(self, resume_threshold: float = 0.1) -> bool: """可用量需超过上限的 10% 才算『足够』,避免边界抖动。""" return self.available >= self.limit * resume_threshold if __name__ == "__main__": q = Quota(used=95, limit=100) print("是否足够恢复:", q.is_sufficient()) # False(仅剩 5%) q.used = 80 print("是否足够恢复:", q.is_sufficient()) # True(剩 20%)

resume_threshold提供滞回:恢复要求「明显够用」,不像暂停那样「一空就停」,从而抑制 flapping。

六、解决方案二:幂等的恢复动作

给任务加「正在恢复中」的中间态,且用任务级锁保证同一时刻只有一个 RUNNING 实例:

from enum import Enum import threading class TaskState(Enum): RUNNING = "running" PAUSED = "paused" RESUMING = "resuming" # 中间态,防重入 CANCELLED = "cancelled" class IdempotentScheduler: def __init__(self): self.state = TaskState.PAUSED self._lock = threading.Lock() def try_resume(self) -> str: with self._lock: if self.state == TaskState.RUNNING: return "已在运行,跳过" if self.state == TaskState.RESUMING: return "恢复中,跳过重复触发" if self.state == TaskState.CANCELLED: return "已取消,不恢复" # 进入 RESUMING 中间态,独占恢复流程 self.state = TaskState.RESUMING # 此处真正启动执行体(释放锁后再做重活,避免持锁过久) try: self._start_executor() with self._lock: self.state = TaskState.RUNNING return "恢复成功" except Exception: with self._lock: self.state = TaskState.PAUSED # 失败回退 return "恢复失败,回 PAUSED" def _start_executor(self): print("真正启动执行体(单实例)") if __name__ == "__main__": s = IdempotentScheduler() print(s.try_resume()) # 恢复成功 print(s.try_resume()) # 已在运行,跳过(不再双实例)

RESUMING中间态 + 锁,确保多次配额刷新事件只会拉起一个执行体,彻底消灭第四节的重复恢复。

七、解决方案三:把配额变化「通知」给调度器

不要靠调度器盲轮询,而是配额管理器在「跨过阈值」时主动回调(事件驱动),轮询只作兜底:

class QuotaManager: def __init__(self): self._was_sufficient = False self._listeners = [] def on_refresh(self, quota: Quota): now = quota.is_sufficient() # 仅在 不足->足够 的上升沿触发恢复,下降沿由任务自己暂停 if now and not self._was_sufficient: for cb in self._listeners: cb() self._was_sufficient = now def subscribe(self, cb): self._listeners.append(cb) if __name__ == "__main__": sched = IdempotentScheduler() qm = QuotaManager() qm.subscribe(lambda: sched.try_resume()) # 配额恢复即自动恢复任务 qm.on_refresh(Quota(used=95, limit=100)) # 不足 -> 不触发 qm.on_refresh(Quota(used=80, limit=100)) # 足够 -> 触发恢复 qm.on_refresh(Quota(used=78, limit=100)) # 仍足够 -> 不重复触发(无上升沿)

上升沿触发是关键:配额从「不够」变「够」才通知一次,之后持续够也不再打扰——既及时又省资源。

八、解决方案四:自适应轮询兜底

事件驱动之外,加一个「根据配额重置周期自适应」的兜底轮询,防止回调丢失:

import time def adaptive_poll(quota_getter, on_recover, reset_period_sec=3600, jitter=0.1): """在配额重置周期附近提高检查频率,平时低频。""" while True: q = quota_getter() if q.is_sufficient(): on_recover() # 平时按周期 1/10 频率,避免空转;真实实现会结合「距下次重置的时间」 sleep = max(30, reset_period_sec / 10) time.sleep(sleep * (1 + jitter)) # 加抖动避免多实例共振

要点:轮询只是兜底,主路径是上升沿事件;频率随重置周期缩放,并在多实例部署时加 jitter 防「惊群」。

九、排查清单

「配额刷新后任务不自动恢复」,按下面排查:

  1. 配额刷新有没有通知调度器?还是两套状态机各自为政;
  2. 恢复触发是上升沿还是持续轮询?持续轮询易重复触发;
  3. 恢复动作幂等吗?有没有「RESUMING 中间态 + 锁」防双实例;
  4. 有没有滞回阈值?边界抖动会不会导致反复暂停/恢复;
  5. 失败回退对吗?恢复失败是否回PAUSED而非卡在中间态;
  6. 轮询频率合理吗?太粗恢复慢、太细空转;是否按重置周期自适应;
  7. 用户手动恢复与自动恢复是否冲突?两者走同一幂等入口;
  8. 状态可观测吗?任务状态、上次恢复时间、触发原因是否可查。

十、小结

「配额刷新后不自动恢复暂停任务」的根因是配额状态机与任务调度状态机脱节,且恢复动作不幂等。通用修复:

  1. 事件驱动 + 上升沿:配额从「不足」跨到「足够」才通知一次(第七节),及时且不重复;
  2. 幂等恢复RESUMING中间态 + 任务级锁,保证同一任务只有一个 RUNNING 实例(第六节,消灭重复恢复);
  3. 滞回阈值:恢复要求「明显够用」而非「刚好够」,抑制边界抖动(第五节);
  4. 自适应兜底轮询:主路径靠事件,轮询按重置周期缩放 + jitter 防惊群(第八节)。 一句话:让配额状态的变化成为任务调度的输入,用幂等和滞回把「自动恢复」做成既及时又不会抽风的能力。这与第 97 篇「升级后额度未重置」、第 110 篇「虚耗计费」共同指向一个主题——配额系统的状态必须被精确建模、精确联动、精确回退。