xiaolongmr commited on
Commit ·
fbd51cc
1
Parent(s): 7820347
yongjiu shibai de fenxiang jilu zhijie tuo li duilie, bu zai rang shangyou chongshi
Browse files
services/share_evidence_service.py
CHANGED
|
@@ -45,7 +45,11 @@ SHARE_EVIDENCE_STORE = DATA_DIR / "share_evidence.json"
|
|
| 45 |
STATUS_PENDING = "pending" # 已登记,等用户在保留期内按需取证
|
| 46 |
STATUS_SHARED = "shared" # 已生成分享链接,链接有效中
|
| 47 |
STATUS_RELEASED = "released" # 已回收(会话已隐藏、分享已撤)
|
| 48 |
-
STATUS_ORPHAN = "orphan" # 账号已不可用,回收失败待重试
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
_ACTIVE_STATUSES = (STATUS_PENDING, STATUS_SHARED, STATUS_ORPHAN)
|
| 51 |
# 回收的兜底周期:正常路径由 `_next_due_ts()` 精确唤醒、到期即回收,这个周期只负责
|
|
@@ -77,6 +81,22 @@ def _is_permanent_failure(exc: BaseException) -> bool:
|
|
| 77 |
return any(marker in text for marker in _PERMANENT_FAILURE_MARKERS)
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def _now_iso() -> str:
|
| 81 |
return datetime.now(timezone.utc).isoformat()
|
| 82 |
|
|
@@ -511,6 +531,7 @@ class ShareEvidenceService:
|
|
| 511 |
and float(record.get("next_attempt_ts") or 0) <= now
|
| 512 |
and effective_expires_ts(record) > now
|
| 513 |
and _clean(record.get("conversation_id")) not in self._inflight
|
|
|
|
| 514 |
]
|
| 515 |
due.sort(key=lambda item: float(item.get("created_ts") or 0))
|
| 516 |
|
|
@@ -537,13 +558,15 @@ class ShareEvidenceService:
|
|
| 537 |
attempts = int(stored.get("attempts") or 0) + 1
|
| 538 |
stored["attempts"] = attempts
|
| 539 |
permanent = _is_permanent_failure(exc) or attempts >= _MAX_RELEASE_ATTEMPTS
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
|
|
|
|
|
|
| 547 |
stored["last_error"] = str(exc)[:300]
|
| 548 |
self._save_locked()
|
| 549 |
logger.warning({
|
|
@@ -557,6 +580,35 @@ class ShareEvidenceService:
|
|
| 557 |
|
| 558 |
# ------------------------------------------------------------------ 回收
|
| 559 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
def reap_expired(self, *, limit: int = 20) -> dict[str, Any]:
|
| 561 |
"""回收所有已过保留期的会话:隐藏会话(顺带废掉链接)+ 删除分享。
|
| 562 |
|
|
@@ -564,6 +616,7 @@ class ShareEvidenceService:
|
|
| 564 |
快照值——站长把保留时长调短后,旧记录必须立刻跟着到期,而不是继续按旧值熬。
|
| 565 |
"""
|
| 566 |
now = time.time()
|
|
|
|
| 567 |
with self._lock:
|
| 568 |
due = [
|
| 569 |
dict(record)
|
|
@@ -574,6 +627,7 @@ class ShareEvidenceService:
|
|
| 574 |
# 「到期即唤醒」精确调度就会变成每 0.5 秒狂刷上游(旧实现靠 5 分钟大
|
| 575 |
# 周期兜着才没暴露)。
|
| 576 |
and float(record.get("next_attempt_ts") or 0) <= now
|
|
|
|
| 577 |
]
|
| 578 |
due.sort(key=effective_expires_ts)
|
| 579 |
|
|
@@ -591,17 +645,18 @@ class ShareEvidenceService:
|
|
| 591 |
stored = self._record_locked(conversation_id)
|
| 592 |
if stored is not None and stored.get("status") in _ACTIVE_STATUSES:
|
| 593 |
attempts = int(stored.get("attempts") or 0) + 1
|
| 594 |
-
stored["status"] = STATUS_ORPHAN
|
| 595 |
stored["attempts"] = attempts
|
| 596 |
-
permanent = _is_permanent_failure(exc) or attempts >= _MAX_RELEASE_ATTEMPTS
|
| 597 |
-
# 30s → 60s → 120s → 240s → 480s → 600s 封顶;永久失败直接远期搁置
|
| 598 |
-
backoff = (
|
| 599 |
-
_LONG_SLEEP_SECONDS
|
| 600 |
-
if permanent
|
| 601 |
-
else min(30 * (2 ** min(attempts - 1, 5)), 600)
|
| 602 |
-
)
|
| 603 |
-
stored["next_attempt_ts"] = time.time() + backoff
|
| 604 |
stored["last_error"] = str(exc)[:300]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
self._save_locked()
|
| 606 |
logger.warning({
|
| 607 |
"event": "share_evidence_release_failed",
|
|
@@ -733,6 +788,10 @@ class ShareEvidenceService:
|
|
| 733 |
for record in self._records.values():
|
| 734 |
if record.get("status") not in _ACTIVE_STATUSES:
|
| 735 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
expires_ts = effective_expires_ts(record)
|
| 737 |
if expires_ts <= 0:
|
| 738 |
continue
|
|
@@ -785,6 +844,12 @@ class ShareEvidenceService:
|
|
| 785 |
self.reconcile_retention()
|
| 786 |
except Exception as exc:
|
| 787 |
logger.warning({"event": "share_evidence_reconcile_error", "error": str(exc)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
def run() -> None:
|
| 790 |
last_reap = time.time()
|
|
|
|
| 45 |
STATUS_PENDING = "pending" # 已登记,等用户在保留期内按需取证
|
| 46 |
STATUS_SHARED = "shared" # 已生成分享链接,链接有效中
|
| 47 |
STATUS_RELEASED = "released" # 已回收(会话已隐藏、分享已撤)
|
| 48 |
+
STATUS_ORPHAN = "orphan" # 账号已不可用,回收失败待重试(仅限可恢复的失败)
|
| 49 |
+
# 上游永久拒绝(token 吊销 / CF 拦截 / 会话已删):重试不可能成功,直接移出队列。
|
| 50 |
+
# ⚠️ 不能用「ORPHAN + 30 天退避」凑合 —— 30 天后又会全量重试一次,而记录只增不减,
|
| 51 |
+
# 每次都把 cpu-basic 打满(2026-10-19 事故复发的直接原因)。
|
| 52 |
+
STATUS_ABANDONED = "abandoned"
|
| 53 |
|
| 54 |
_ACTIVE_STATUSES = (STATUS_PENDING, STATUS_SHARED, STATUS_ORPHAN)
|
| 55 |
# 回收的兜底周期:正常路径由 `_next_due_ts()` 精确唤醒、到期即回收,这个周期只负责
|
|
|
|
| 81 |
return any(marker in text for marker in _PERMANENT_FAILURE_MARKERS)
|
| 82 |
|
| 83 |
|
| 84 |
+
def _hopeless(record: dict[str, Any]) -> bool:
|
| 85 |
+
"""该记录是否已经不可能成功、不该再碰上游。
|
| 86 |
+
|
| 87 |
+
两种:① 已被判为永久失败(历史遗留的 ``permanent`` / ``abandoned`` 标记);
|
| 88 |
+
② 尝试次数超过上限(老版本会无限重试,台账里积压了大量 attempts 200+ 的记录)。
|
| 89 |
+
"""
|
| 90 |
+
if record.get("status") not in _ACTIVE_STATUSES:
|
| 91 |
+
return False
|
| 92 |
+
if record.get("permanent"):
|
| 93 |
+
return True
|
| 94 |
+
try:
|
| 95 |
+
return int(record.get("attempts") or 0) >= _MAX_RELEASE_ATTEMPTS
|
| 96 |
+
except (TypeError, ValueError):
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
|
| 100 |
def _now_iso() -> str:
|
| 101 |
return datetime.now(timezone.utc).isoformat()
|
| 102 |
|
|
|
|
| 531 |
and float(record.get("next_attempt_ts") or 0) <= now
|
| 532 |
and effective_expires_ts(record) > now
|
| 533 |
and _clean(record.get("conversation_id")) not in self._inflight
|
| 534 |
+
and not _hopeless(record)
|
| 535 |
]
|
| 536 |
due.sort(key=lambda item: float(item.get("created_ts") or 0))
|
| 537 |
|
|
|
|
| 558 |
attempts = int(stored.get("attempts") or 0) + 1
|
| 559 |
stored["attempts"] = attempts
|
| 560 |
permanent = _is_permanent_failure(exc) or attempts >= _MAX_RELEASE_ATTEMPTS
|
| 561 |
+
if permanent:
|
| 562 |
+
# 上游已判死刑:直接移出队列,别再让它在下次重启后重来。
|
| 563 |
+
stored["status"] = STATUS_ABANDONED
|
| 564 |
+
stored["permanent"] = True
|
| 565 |
+
else:
|
| 566 |
+
# 30s → 60s → 120s → 240s → 480s → 600s 封顶
|
| 567 |
+
stored["next_attempt_ts"] = time.time() + min(
|
| 568 |
+
30 * (2 ** min(attempts - 1, 5)), 600
|
| 569 |
+
)
|
| 570 |
stored["last_error"] = str(exc)[:300]
|
| 571 |
self._save_locked()
|
| 572 |
logger.warning({
|
|
|
|
| 580 |
|
| 581 |
# ------------------------------------------------------------------ 回收
|
| 582 |
|
| 583 |
+
def _abandon_hopeless(self) -> int:
|
| 584 |
+
"""把所有「重试不可能成功」的记录一次性移出队列,且**不碰上游**。
|
| 585 |
+
|
| 586 |
+
必须在做任何 due 计算之前跑:老版本在台账里留下了大量 attempts 200+ 的
|
| 587 |
+
401/403 记录,每次容器重启都会整批重新尝试,每一条都要等 Cloudflare JS
|
| 588 |
+
挑战超时(CPU 密集、持 GIL),2 核直接被吃满,整站连 /health 都不响应。
|
| 589 |
+
只有「启动即清理、且永不重试」才能彻底断掉这个循环。
|
| 590 |
+
"""
|
| 591 |
+
with self._lock:
|
| 592 |
+
victims = [cid for cid, rec in self._records.items() if _hopeless(rec)]
|
| 593 |
+
if not victims:
|
| 594 |
+
return 0
|
| 595 |
+
now = time.time()
|
| 596 |
+
for conversation_id in victims:
|
| 597 |
+
record = self._records.get(conversation_id)
|
| 598 |
+
if record is None:
|
| 599 |
+
continue
|
| 600 |
+
record["status"] = STATUS_ABANDONED
|
| 601 |
+
record["permanent"] = True
|
| 602 |
+
record["next_attempt_ts"] = now + _LONG_SLEEP_SECONDS
|
| 603 |
+
record.setdefault("last_error", "上游永久拒绝(401/403/404)或尝试次数超限,已停止重试")
|
| 604 |
+
self._save_locked()
|
| 605 |
+
logger.info({
|
| 606 |
+
"event": "share_evidence_abandoned_hopeless",
|
| 607 |
+
"count": len(victims),
|
| 608 |
+
"reason": "permanent_upstream_rejection_or_attempt_cap",
|
| 609 |
+
})
|
| 610 |
+
return len(victims)
|
| 611 |
+
|
| 612 |
def reap_expired(self, *, limit: int = 20) -> dict[str, Any]:
|
| 613 |
"""回收所有已过保留期的会话:隐藏会话(顺带废掉链接)+ 删除分享。
|
| 614 |
|
|
|
|
| 616 |
快照值——站长把保留时长调短后,旧记录必须立刻跟着到期,而不是继续按旧值熬。
|
| 617 |
"""
|
| 618 |
now = time.time()
|
| 619 |
+
self._abandon_hopeless()
|
| 620 |
with self._lock:
|
| 621 |
due = [
|
| 622 |
dict(record)
|
|
|
|
| 627 |
# 「到期即唤醒」精确调度就会变成每 0.5 秒狂刷上游(旧实现靠 5 分钟大
|
| 628 |
# 周期兜着才没暴露)。
|
| 629 |
and float(record.get("next_attempt_ts") or 0) <= now
|
| 630 |
+
and not _hopeless(record)
|
| 631 |
]
|
| 632 |
due.sort(key=effective_expires_ts)
|
| 633 |
|
|
|
|
| 645 |
stored = self._record_locked(conversation_id)
|
| 646 |
if stored is not None and stored.get("status") in _ACTIVE_STATUSES:
|
| 647 |
attempts = int(stored.get("attempts") or 0) + 1
|
|
|
|
| 648 |
stored["attempts"] = attempts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
stored["last_error"] = str(exc)[:300]
|
| 650 |
+
if _is_permanent_failure(exc) or attempts >= _MAX_RELEASE_ATTEMPTS:
|
| 651 |
+
# 永久失败:标记后永久移出队列。之前只做「30 天远期搁置」,
|
| 652 |
+
# 重启后整批重新到期 → 又全体撞一次上游 403 → V8 求解打满 CPU。
|
| 653 |
+
stored["status"] = STATUS_ABANDONED
|
| 654 |
+
stored["permanent"] = True
|
| 655 |
+
else:
|
| 656 |
+
stored["status"] = STATUS_ORPHAN
|
| 657 |
+
stored["next_attempt_ts"] = time.time() + min(
|
| 658 |
+
30 * (2 ** min(attempts - 1, 5)), 600
|
| 659 |
+
)
|
| 660 |
self._save_locked()
|
| 661 |
logger.warning({
|
| 662 |
"event": "share_evidence_release_failed",
|
|
|
|
| 788 |
for record in self._records.values():
|
| 789 |
if record.get("status") not in _ACTIVE_STATUSES:
|
| 790 |
continue
|
| 791 |
+
if _hopeless(record):
|
| 792 |
+
# 已判死刑但还没被 _abandon_hopeless 收走的记录,不能参与唤醒,
|
| 793 |
+
# 否则 worker 会被它反复叫醒、空转抢 CPU。
|
| 794 |
+
continue
|
| 795 |
expires_ts = effective_expires_ts(record)
|
| 796 |
if expires_ts <= 0:
|
| 797 |
continue
|
|
|
|
| 844 |
self.reconcile_retention()
|
| 845 |
except Exception as exc:
|
| 846 |
logger.warning({"event": "share_evidence_reconcile_error", "error": str(exc)})
|
| 847 |
+
# 启动先清掉不可能成功的积压记录,**在**任何一轮 due 计算之前。否则重启后
|
| 848 |
+
# 整批 401/403 会重新撞上游(403 → V8 求解打满 2 核 → 整站无响应)。
|
| 849 |
+
try:
|
| 850 |
+
self._abandon_hopeless()
|
| 851 |
+
except Exception as exc:
|
| 852 |
+
logger.warning({"event": "share_evidence_abandon_error", "error": str(exc)})
|
| 853 |
|
| 854 |
def run() -> None:
|
| 855 |
last_reap = time.time()
|