Ctrl K
ComunidadGuías…

scripts/xkb_requeue_failed.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""把失敗的項目放回佇列——有次數上限,而且放棄之後不再回來。

2026-09-01 到 09-09 模型設定壞掉那八天留下 49 筆 failed。根因修好之後它們仍然
停在那裡:worker 只挑 status == "todo",而 sync_tiege_queue 明文「保留 failed/
skipped,由操作者自行重設」。也就是說**修好根因不會讓它們自己好**,要人手動改
佇列——2026-09-10 那次就是我手動重設的。

但「一律自動重試」也不對。手動重試那兩輪之後剩下的 10 筆,全部是被供應商以 403
「上游服务拒绝访问」擋掉的內容,而同一把金鑰跑一般請求正常——那是內容被上游擋,
不是暫時性故障。無限重試等於每晚固定燒一次錢又永遠不會成功。

所以規則是:**重試有次數上限,而且到達上限就明確放棄。**

    failed  ──(attempts < MAX 且距上次夠久)──▶  todo
    failed  ──(attempts >= MAX)────────────▶  abandoned

`abandoned` 是終點,跟 `failed` 分開是為了讓「還會再試」和「已經不試了」在報表上
看得出來。放棄不是刪除:項目與最後一次的錯誤都留著,隨時可以手動放回去。

退避是為了不要在同一個晚上把三次機會用完——供應商的暫時性故障(429/503)要隔一
段時間才有意義,而內容被擋(403)隔多久都一樣,靠的是次數上限收斂。

用法:
    python3 scripts/xkb_requeue_failed.py            # 依規則放回
    python3 scripts/xkb_requeue_failed.py --dry-run
    python3 scripts/xkb_requeue_failed.py --reset-abandoned   # 手動把放棄的放回
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import xkb_console
import xkb_failures
import xkb_paths

xkb_console.use_utf8()

# 試幾次才放棄。每天一輪的話,一筆內容被上游擋住的項目會在三天內收斂。
MAX_ATTEMPTS = 3

# 兩次嘗試之間至少隔多久。批次一天跑一次,所以這個值只在手動連跑時起作用——
# 它防的是「同一個晚上把三次機會用完」。
BACKOFF = timedelta(hours=20)


def _parse_ts(value: str) -> datetime | None:
    try:
        parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
    except (TypeError, ValueError):
        return None
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


def _eligible(item: dict, now: datetime) -> bool:
    last = _parse_ts(item.get("finished_at") or item.get("started_at") or "")
    if last is None:
        return True          # 沒有時間就當作可以試——時間讀不出來不該讓它卡死
    return now - last >= BACKOFF


def requeue(queue_path: Path, *, dry_run: bool = False,
            max_attempts: int = MAX_ATTEMPTS) -> dict[str, int]:
    """回傳 {requeued, abandoned, waiting}。"""
    try:
        data = json.loads(queue_path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as err:
        xkb_failures.note("requeue failed items", err, detail=str(queue_path))
        return {"requeued": 0, "abandoned": 0, "waiting": 0, "error": 1}

    items = data.get("items", []) if isinstance(data, dict) else data
    now = datetime.now(timezone.utc)
    counts = {"requeued": 0, "abandoned": 0, "waiting": 0}

    for item in items:
        if item.get("status") != "failed":
            continue
        attempts = int(item.get("attempts") or 1)
        if attempts >= max_attempts:
            item["status"] = "abandoned"
            item["abandoned_at"] = now.isoformat()
            counts["abandoned"] += 1
            continue
        if not _eligible(item, now):
            counts["waiting"] += 1
            continue
        item["status"] = "todo"
        item["attempts"] = attempts + 1
        counts["requeued"] += 1

    if not dry_run and (counts["requeued"] or counts["abandoned"]):
        try:
            queue_path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
                                  encoding="utf-8")
        except OSError as err:
            xkb_failures.note("requeue failed items", err, detail=str(queue_path))
            return {**counts, "error": 1}
    return counts


def reset_abandoned(queue_path: Path, *, dry_run: bool = False) -> int:
    """手動把放棄的全部放回佇列,次數歸零。給「供應商換了、值得再試一輪」用。"""
    data = json.loads(queue_path.read_text(encoding="utf-8"))
    items = data.get("items", []) if isinstance(data, dict) else data
    n = 0
    for item in items:
        if item.get("status") == "abandoned":
            item["status"] = "todo"
            item["attempts"] = 0
            item.pop("abandoned_at", None)
            n += 1
    if n and not dry_run:
        queue_path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
                              encoding="utf-8")
    return n


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--reset-abandoned", action="store_true",
                        help="把已放棄的全部放回佇列(次數歸零)")
    parser.add_argument("--queue", type=Path, default=xkb_paths.QUEUE_PATH)
    args = parser.parse_args()

    if not args.queue.exists():
        print(f"佇列不存在:{args.queue}")
        return 1

    if args.reset_abandoned:
        n = reset_abandoned(args.queue, dry_run=args.dry_run)
        print(f"放回佇列:{n} 筆(次數歸零)")
        return 0

    counts = requeue(args.queue, dry_run=args.dry_run)
    if counts.get("error"):
        print("佇列讀寫失敗——詳見 stderr")
        return 1

    bits = []
    if counts["requeued"]:
        bits.append(f"放回 {counts['requeued']} 筆")
    if counts["abandoned"]:
        bits.append(f"放棄 {counts['abandoned']} 筆(已達 {MAX_ATTEMPTS} 次上限)")
    if counts["waiting"]:
        bits.append(f"{counts['waiting']} 筆還在退避期")
    print(";".join(bits) if bits else "沒有需要處理的失敗項目")
    return 0


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    raise SystemExit(main())