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:
ericwyuan
2026-09-01 18:15:26 +08:00
parent 05f55e695b
commit 7fd4106b0a
2 changed files with 170 additions and 14 deletions

View File

@@ -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)