xiaolongmr commited on
Commit
5760f8d
·
1 Parent(s): fbd51cc

tupian duxie ga dao duli xianchengchi, bing jiaru shijian xunhuan kadun tanzhen

Browse files
Files changed (2) hide show
  1. api/app.py +24 -1
  2. 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 run_in_threadpool(get_image_response, image_path)
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 run_in_threadpool(_download_image_url, url)
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 run_in_threadpool(get_thumbnail_response, image_path)
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 delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
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
- buf = download_images_zip(body.paths)
 
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 run_in_threadpool(get_image_download_response, image_path)
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
- stats = await run_in_threadpool(acct_svc.get_stats)
 
 
 
 
760
  storage = config.get_storage_backend()
761
- storage_health = await run_in_threadpool(storage.health_check)
 
 
 
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",