xiaolongmr commited on
Commit ·
5760f8d
1
Parent(s): fbd51cc
tupian duxie ga dao duli xianchengchi, bing jiaru shijian xunhuan kadun tanzhen
Browse files- api/app.py +24 -1
- api/system.py +46 -9
api/app.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
from html import escape
|
| 5 |
from mimetypes import guess_type
|
| 6 |
from pathlib import Path
|
| 7 |
import re
|
| 8 |
-
from threading import Event, Thread
|
| 9 |
import time
|
| 10 |
|
| 11 |
from fastapi import FastAPI, HTTPException, Request
|
|
@@ -156,6 +157,26 @@ def _web_response(full_path: str, request: Request, asset: Path) -> Response:
|
|
| 156 |
def create_app() -> FastAPI:
|
| 157 |
app_version = config.app_version
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
@asynccontextmanager
|
| 160 |
async def lifespan(_: FastAPI):
|
| 161 |
# anyio's worker-thread limiter defaults to 40 and is shared by every
|
|
@@ -171,9 +192,11 @@ def create_app() -> FastAPI:
|
|
| 171 |
share_evidence_service.start()
|
| 172 |
_start_bundled_skill_sync()
|
| 173 |
_start_image_cleanup()
|
|
|
|
| 174 |
try:
|
| 175 |
yield
|
| 176 |
finally:
|
|
|
|
| 177 |
stop_event.set()
|
| 178 |
thread.join(timeout=1)
|
| 179 |
backup_service.stop()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
from contextlib import asynccontextmanager
|
| 5 |
from html import escape
|
| 6 |
from mimetypes import guess_type
|
| 7 |
from pathlib import Path
|
| 8 |
import re
|
| 9 |
+
from threading import Event, Thread, active_count
|
| 10 |
import time
|
| 11 |
|
| 12 |
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
| 157 |
def create_app() -> FastAPI:
|
| 158 |
app_version = config.app_version
|
| 159 |
|
| 160 |
+
async def _loop_lag_watchdog() -> None:
|
| 161 |
+
"""把事件循环的真实卡顿写进日志。
|
| 162 |
+
|
| 163 |
+
⚠️ 排查「整站无响应」时,容器日志一切正常(后台线程照跑)却测不出来到底卡在哪,
|
| 164 |
+
就是因为没有任何关于事件循环本身的观测。这个探针每 20 秒量一次 sleep 漂移:
|
| 165 |
+
漂移 = 循环线程被同步调用/GIL 抢占的实际时长。
|
| 166 |
+
"""
|
| 167 |
+
from utils.log import logger as _logger
|
| 168 |
+
|
| 169 |
+
while True:
|
| 170 |
+
started = time.perf_counter()
|
| 171 |
+
await asyncio.sleep(20)
|
| 172 |
+
lag_ms = round((time.perf_counter() - started - 20) * 1000)
|
| 173 |
+
if lag_ms >= 800:
|
| 174 |
+
_logger.warning({
|
| 175 |
+
"event": "event_loop_lag",
|
| 176 |
+
"lag_ms": lag_ms,
|
| 177 |
+
"threads": active_count(),
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
@asynccontextmanager
|
| 181 |
async def lifespan(_: FastAPI):
|
| 182 |
# anyio's worker-thread limiter defaults to 40 and is shared by every
|
|
|
|
| 192 |
share_evidence_service.start()
|
| 193 |
_start_bundled_skill_sync()
|
| 194 |
_start_image_cleanup()
|
| 195 |
+
watchdog = asyncio.create_task(_loop_lag_watchdog())
|
| 196 |
try:
|
| 197 |
yield
|
| 198 |
finally:
|
| 199 |
+
watchdog.cancel()
|
| 200 |
stop_event.set()
|
| 201 |
thread.join(timeout=1)
|
| 202 |
backup_service.stop()
|
api/system.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import json
|
|
|
|
| 4 |
from typing import Any
|
| 5 |
from urllib.parse import quote, urlparse
|
| 6 |
|
|
@@ -28,6 +31,32 @@ from services.auth_service import auth_service
|
|
| 28 |
from services.image_service import delete_images, download_images_zip, get_image_download_response, get_image_response, get_thumbnail_response, list_images
|
| 29 |
from services.image_storage_service import ImageStorageError, image_storage_service
|
| 30 |
from services.image_tags_service import delete_tag, get_all_tags, set_tags
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from services.proxy_service import proxy_settings, test_clearance, test_proxy
|
| 32 |
from services.share_evidence_service import share_evidence_service
|
| 33 |
from services.pool_pricing_service import pool_pricing_service
|
|
@@ -527,7 +556,7 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 527 |
# 线程上跑;而图片是全站最热的请求,几十人同时刷图会把事件循环彻底堵死 ⇒ 连 /health、
|
| 528 |
# 静态资源和页面 SSR 都一起排队超时(2026-10-16 线上实测:/health 连续 20~25s 无响应,
|
| 529 |
# 而同一 commit 的空闲容器 1s 返回 200)。
|
| 530 |
-
return await
|
| 531 |
|
| 532 |
@router.get("/api/images/proxy")
|
| 533 |
async def proxy_image(url: str, authorization: str | None = Header(default=None)):
|
|
@@ -535,7 +564,7 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 535 |
parsed = urlparse(str(url or "").strip())
|
| 536 |
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 537 |
raise HTTPException(status_code=400, detail={"error": "invalid image url"})
|
| 538 |
-
data, _filename, content_type = await
|
| 539 |
return Response(
|
| 540 |
content=data,
|
| 541 |
media_type=content_type,
|
|
@@ -546,17 +575,18 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 546 |
async def get_image_thumbnail(image_path: str):
|
| 547 |
# ⚠️ 同上:ensure_thumbnail 会读原图 + 用 PIL 做 LANCZOS 重采样并 PNG 编码,
|
| 548 |
# 是纯 CPU 密集的阻塞活,放事件循环上跑会把所有请求一起拖死。
|
| 549 |
-
return await
|
| 550 |
|
| 551 |
@router.post("/api/images/delete")
|
| 552 |
async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
|
| 553 |
require_admin(authorization)
|
| 554 |
-
return
|
| 555 |
|
| 556 |
@router.post("/api/images/download")
|
| 557 |
async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
|
| 558 |
require_admin(authorization)
|
| 559 |
-
|
|
|
|
| 560 |
return StreamingResponse(
|
| 561 |
buf,
|
| 562 |
media_type="application/zip",
|
|
@@ -566,8 +596,8 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 566 |
@router.get("/api/images/download/{image_path:path}")
|
| 567 |
async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
|
| 568 |
require_admin(authorization)
|
| 569 |
-
# ⚠️ 同样会把整张图从网络 Bucket 读进内存,必须进线程池。
|
| 570 |
-
return await
|
| 571 |
|
| 572 |
@router.get("/api/logs")
|
| 573 |
async def get_logs(type: str = "", start_date: str = "", end_date: str = "", limit: int = 200, authorization: str | None = Header(default=None)):
|
|
@@ -756,9 +786,16 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 756 |
# ⚠️ 健康检查要保证「容器越忙也测得出来」:这两个都是同步读库/探活的阻塞调用,
|
| 757 |
# 直接放事件循环上跑的话,图片请求一多,/health 自己就先被堵死,反而失去判活能力
|
| 758 |
# (2026-10-16 线上现象:/health 20~25s 超时,让人误以为容器已死)。
|
| 759 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
storage = config.get_storage_backend()
|
| 761 |
-
|
|
|
|
|
|
|
|
|
|
| 762 |
healthy = int(stats.get("active") or 0) > 0
|
| 763 |
payload = {
|
| 764 |
"status": "ok" if healthy else "degraded",
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
+
import functools
|
| 5 |
import json
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
from typing import Any
|
| 8 |
from urllib.parse import quote, urlparse
|
| 9 |
|
|
|
|
| 31 |
from services.image_service import delete_images, download_images_zip, get_image_download_response, get_image_response, get_thumbnail_response, list_images
|
| 32 |
from services.image_storage_service import ImageStorageError, image_storage_service
|
| 33 |
from services.image_tags_service import delete_tag, get_all_tags, set_tags
|
| 34 |
+
|
| 35 |
+
# ⚠️⚠️ 图片读写必须走**独立线程池**,不能和 StaticFiles / 其他 run_in_threadpool 共用
|
| 36 |
+
# AnyIO 的默认线程池(本服务设成 50)。原因:图片存在**网络挂载**的 Storage Bucket 上,
|
| 37 |
+
# 单张 2.8MB 在桶上读一次可能要几十秒;几十人同时刷一屏图(每人 6~9 张)会把 50 个共享
|
| 38 |
+
# 线程全部占满,后面排队的 HTML、JS、/health 全部超时 —— 表现是「连接被关闭 / 一个字节
|
| 39 |
+
# 都不回」(2026-10-19 全站无响应事故,后台线程日志一切正常,就是 HTTP 不响应)。
|
| 40 |
+
# 独立池 = 图片再慢也只排队在自己这里,页面和接口照常可用。
|
| 41 |
+
_IMAGE_IO_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="image-io")
|
| 42 |
+
# 单张图片在桶上读不完时的上限:超时直接 503 + Retry-After,让浏览器/CF 稍后重试。
|
| 43 |
+
# ⚠️ 不能无限排队:几百个请求排在 8 个 worker 后面,会变成十几分钟的积压,
|
| 44 |
+
# 用户看到的就是「页面一直在转」,而 CF 也会一直保持连接。
|
| 45 |
+
_IMAGE_IO_TIMEOUT_SECONDS = 25.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def _run_image_io(func, *args):
|
| 49 |
+
"""在图片专用线程池里跑阻塞的图片读写,隔离出共享线程池。"""
|
| 50 |
+
loop = asyncio.get_running_loop()
|
| 51 |
+
future = loop.run_in_executor(_IMAGE_IO_POOL, functools.partial(func, *args))
|
| 52 |
+
try:
|
| 53 |
+
return await asyncio.wait_for(future, timeout=_IMAGE_IO_TIMEOUT_SECONDS)
|
| 54 |
+
except asyncio.TimeoutError:
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=503,
|
| 57 |
+
detail={"error": "image io timeout"},
|
| 58 |
+
headers={"Retry-After": "10"},
|
| 59 |
+
)
|
| 60 |
from services.proxy_service import proxy_settings, test_clearance, test_proxy
|
| 61 |
from services.share_evidence_service import share_evidence_service
|
| 62 |
from services.pool_pricing_service import pool_pricing_service
|
|
|
|
| 556 |
# 线程上跑;而图片是全站最热的请求,几十人同时刷图会把事件循环彻底堵死 ⇒ 连 /health、
|
| 557 |
# 静态资源和页面 SSR 都一起排队超时(2026-10-16 线上实测:/health 连续 20~25s 无响应,
|
| 558 |
# 而同一 commit 的空闲容器 1s 返回 200)。
|
| 559 |
+
return await _run_image_io(get_image_response, image_path)
|
| 560 |
|
| 561 |
@router.get("/api/images/proxy")
|
| 562 |
async def proxy_image(url: str, authorization: str | None = Header(default=None)):
|
|
|
|
| 564 |
parsed = urlparse(str(url or "").strip())
|
| 565 |
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 566 |
raise HTTPException(status_code=400, detail={"error": "invalid image url"})
|
| 567 |
+
data, _filename, content_type = await _run_image_io(_download_image_url, url)
|
| 568 |
return Response(
|
| 569 |
content=data,
|
| 570 |
media_type=content_type,
|
|
|
|
| 575 |
async def get_image_thumbnail(image_path: str):
|
| 576 |
# ⚠️ 同上:ensure_thumbnail 会读原图 + 用 PIL 做 LANCZOS 重采样并 PNG 编码,
|
| 577 |
# 是纯 CPU 密集的阻塞活,放事件循环上跑会把所有请求一起拖死。
|
| 578 |
+
return await _run_image_io(get_thumbnail_response, image_path)
|
| 579 |
|
| 580 |
@router.post("/api/images/delete")
|
| 581 |
async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
|
| 582 |
require_admin(authorization)
|
| 583 |
+
return await _run_image_io(delete_images, body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
|
| 584 |
|
| 585 |
@router.post("/api/images/download")
|
| 586 |
async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
|
| 587 |
require_admin(authorization)
|
| 588 |
+
# 打包下载要逐张从网络 Bucket 读原图,阻塞时间比单张更长 —— 更不能占共享线程池。
|
| 589 |
+
buf = await _run_image_io(download_images_zip, body.paths)
|
| 590 |
return StreamingResponse(
|
| 591 |
buf,
|
| 592 |
media_type="application/zip",
|
|
|
|
| 596 |
@router.get("/api/images/download/{image_path:path}")
|
| 597 |
async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
|
| 598 |
require_admin(authorization)
|
| 599 |
+
# ⚠️ 同样会把整张图从网络 Bucket 读进内存,必须进**图片专用**线程池。
|
| 600 |
+
return await _run_image_io(get_image_download_response, image_path)
|
| 601 |
|
| 602 |
@router.get("/api/logs")
|
| 603 |
async def get_logs(type: str = "", start_date: str = "", end_date: str = "", limit: int = 200, authorization: str | None = Header(default=None)):
|
|
|
|
| 786 |
# ⚠️ 健康检查要保证「容器越忙也测得出来」:这两个都是同步读库/探活的阻塞调用,
|
| 787 |
# 直接放事件循环上跑的话,图片请求一多,/health 自己就先被堵死,反而失去判活能力
|
| 788 |
# (2026-10-16 线上现象:/health 20~25s 超时,让人误以为容器已死)。
|
| 789 |
+
# 再加一层硬超时:线程池被占满时也必须秒回,否则连"容器是否活着"都测不出来。
|
| 790 |
+
try:
|
| 791 |
+
stats = await asyncio.wait_for(run_in_threadpool(acct_svc.get_stats), timeout=3.0)
|
| 792 |
+
except Exception:
|
| 793 |
+
stats = {"active": 0, "total": 0, "unavailable_stats": True}
|
| 794 |
storage = config.get_storage_backend()
|
| 795 |
+
try:
|
| 796 |
+
storage_health = await asyncio.wait_for(run_in_threadpool(storage.health_check), timeout=3.0)
|
| 797 |
+
except Exception:
|
| 798 |
+
storage_health = {"ok": False, "error": "timeout"}
|
| 799 |
healthy = int(stats.get("active") or 0) > 0
|
| 800 |
payload = {
|
| 801 |
"status": "ok" if healthy else "degraded",
|