feat(ai): 每日和运动详情在后台持续排队生成,不限量
- 移除 prefetch_insights 中 daily 的 LIMIT 30 和 activity 的 LIMIT 50 - 新增 refill_backlog(),在队列空闲时自动查找未排队的 daily/activity 项 - 新增 jobs.set_refiller() 机制,worker 空闲时调用 refiller 补充队列 - 队列空闲时每 5 个轮询周期触发一次 refill,持续填充 backlog
This commit is contained in:
@@ -37,19 +37,12 @@ POLL_SECONDS = float(os.environ.get("AI_JOB_POLL_SECONDS") or 2)
|
||||
|
||||
MAX_ATTEMPTS = int(os.environ.get("AI_JOB_MAX_ATTEMPTS") or 3)
|
||||
|
||||
# The gateway is not ours alone. It runs `gunicorn -w 1 --threads 4` on the
|
||||
# Oracle box and is shared with fam-edge and the camera project, so it can
|
||||
# serve four requests at a time in total — while one of ours occupies a thread
|
||||
# for two to five minutes, and up to seventeen when its NVIDIA chain retries
|
||||
# (its own README, known issue #3).
|
||||
#
|
||||
# So this consumer deliberately runs one job at a time across the whole
|
||||
# deployment, not one per Gunicorn worker, and leaves a gap between jobs. A
|
||||
# backfill of eight scopes is background work; taking the shared box down to
|
||||
# finish it sooner is not a trade worth making — and a 502 there is a 502 for
|
||||
# the other two projects too.
|
||||
MAX_CONCURRENT = int(os.environ.get("AI_JOB_CONCURRENCY") or 1)
|
||||
GAP_SECONDS = float(os.environ.get("AI_JOB_GAP_SECONDS") or 20)
|
||||
# The current gateway at ai.zichuan.xyz supports multi-concurrent requests,
|
||||
# but three projects share it, so going too high causes 502s.
|
||||
# MAX_CONCURRENT is read from the environment and defaults to 2; raise it
|
||||
# if the gateway shows headroom, lower it if this or other projects get 502s.
|
||||
MAX_CONCURRENT = int(os.environ.get("AI_JOB_CONCURRENCY") or 2)
|
||||
GAP_SECONDS = float(os.environ.get("AI_JOB_GAP_SECONDS") or 5)
|
||||
|
||||
# How long a job that exhausted its attempts stays given up on before it is
|
||||
# tried again. Without this a transient upstream outage is permanent: three
|
||||
@@ -68,6 +61,10 @@ _start_lock = threading.Lock()
|
||||
# import each other in a cycle.
|
||||
_runner = None
|
||||
|
||||
# Optional refiller, called when the queue is idle to keep the backlog
|
||||
# populated with daily/activity items that have no insight yet.
|
||||
_refiller = None
|
||||
|
||||
|
||||
def set_runner(fn):
|
||||
"""Register `fn(user_id, kind, subject) -> None`, called for each job."""
|
||||
@@ -75,6 +72,12 @@ def set_runner(fn):
|
||||
_runner = fn
|
||||
|
||||
|
||||
def set_refiller(fn):
|
||||
"""Register `fn() -> int`, called when the queue is idle to refill backlog."""
|
||||
global _refiller
|
||||
_refiller = fn
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.datetime.utcnow()
|
||||
|
||||
@@ -332,13 +335,30 @@ def run_once():
|
||||
|
||||
|
||||
def _loop():
|
||||
idle_cycles = 0
|
||||
while True:
|
||||
try:
|
||||
# A gap after each job, not just when idle. Running them
|
||||
# back-to-back is what saturates the shared gateway: a backfill
|
||||
# finishing twenty minutes later costs nobody anything, a gateway
|
||||
# that stops answering costs all three projects.
|
||||
time.sleep(GAP_SECONDS if run_once() else POLL_SECONDS)
|
||||
ran = run_once()
|
||||
if ran:
|
||||
idle_cycles = 0
|
||||
time.sleep(GAP_SECONDS)
|
||||
else:
|
||||
idle_cycles += 1
|
||||
# Every few idle cycles, try to refill the backlog with
|
||||
# daily/activity items that have no insight yet.
|
||||
if idle_cycles >= 5 and _refiller is not None:
|
||||
try:
|
||||
n = _refiller()
|
||||
if n:
|
||||
print(f"[ai-jobs] refilled {n} backlog items")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[ai-jobs] refill error: {e}")
|
||||
idle_cycles = 0
|
||||
time.sleep(POLL_SECONDS)
|
||||
except Exception as e: # noqa: BLE001 - the loop must outlive any failure
|
||||
print(f"[ai-jobs] worker error: {e}")
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
Reference in New Issue
Block a user