Skip to content

Commit 987de76

Browse files
Merge pull request #19 from 2686521696/main
线上事故三修:Neon 初始化堵死 / 推演阻塞事件循环 / 缩略图 PNG→WebP
2 parents 41fdf3b + 6e8dc4c commit 987de76

11 files changed

Lines changed: 1124 additions & 36 deletions

File tree

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ SESSION_SECRET=replace_with_a_64_char_random_hex_secret
138138
# ⚠️ 从第 3 级开始数据与远端分叉:远端恢复后本地这段时间的记录不会自动回流,
139139
# 需要人工导出/合并。降级时日志会打印"现在写在哪"。
140140
#
141+
# 直接走 Neon SQL over HTTP,跳过 TCP(默认关,2026-08-02 事故后加)。
142+
#
143+
# 什么时候需要它:TCP **能连上、但是慢**的环境。上面那套降级只在 TCP
144+
# 抛异常时才轮到 HTTP,而"慢"不抛异常,于是永远走不到——线上就是这么被堵死的。
145+
#
146+
# 两条通道实测(同一个库同一份数据):
147+
# TCP Neon pooler 解析出 6 个地址(3 IPv4 + 3 IPv6),psycopg 逐个试、
148+
# 每个 connect_timeout=4s,最坏单次连接 24s。
149+
# HTTP 共享 httpx 连接(keep-alive)、每次查询 15s 硬超时,不会卡住不返回。
150+
# 实测 p50 77ms(= 网络往返)、并发 8 吞吐 31 条/s;11 个存储接口方法
151+
# 全部自实现,与 SQLAlchemy 后端功能逐项对齐(含版本链/继承/删除)。
152+
#
153+
# 只对 *.neon.tech 生效;自建 PG/RDS 没有这个端点,设了会被忽略。
154+
# 它自己连不上时照旧继续往下降级(SQLite → JSON)。
155+
# APP_STORE_NEON_HTTP=false
156+
141157
# 本地 SQLite 档的路径(默认如下)。置空则跳过这一级、直接用 JSON——
142158
# 只读文件系统或不想在磁盘上留库文件时用得上:
143159
# APP_STORE_LOCAL_SQLITE=sqlite:///data/sliderule-apps.db

client/src/lib/thumb-capture.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,30 @@ function cropToAspect(
9999
return out;
100100
}
101101

102+
/**
103+
* WebP 编码质量。与服务端 thumb_image.WEBP_QUALITY 保持一致(0~1 vs 0~100)。
104+
*
105+
* 参照:Next.js Image 默认 75、thumbor 默认 80、imgproxy 默认 80。取 0.82 是
106+
* 因为这些是**界面截图**——大片纯色加细字,比照片更吃量化噪声,稍高一档更稳。
107+
*/
108+
const WEBP_QUALITY = 0.82;
109+
110+
/**
111+
* 采出来的画面编码成 blob。
112+
*
113+
* **直接出 WebP,不出 PNG**(2026-08-02)。实测同样分辨率下 805KB PNG →
114+
* 43KB WebP,小 19 倍,而分辨率一个像素不减。这条省的是两段流量:回传时的
115+
* 上行,以及之后每个访客看这张卡的下行(后者才是大头——应用中心一次首屏
116+
* 23 张卡,10.7MB → 约 0.9MB)。
117+
*
118+
* 服务端也会再压一次(thumb_image.to_webp),那是给参照板那一路和历史存量用的;
119+
* 已经是 WebP 的它会原样放行,不会重复编码掉画质。
120+
*
121+
* canvas.toBlob 对不认识的 type 会**静默回落成 PNG**(规范如此),所以这里
122+
* 显式检查一次实际拿到的类型——回落了就如实按 PNG 走,不假装省了带宽。
123+
*/
102124
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
103-
return new Promise(resolve => canvas.toBlob(resolve, "image/png"));
125+
return new Promise(resolve => canvas.toBlob(resolve, "image/webp", WEBP_QUALITY));
104126
}
105127

106128
/** 浏览器空闲时再动手——采集是给缩略图用的,永远排在用户交互后面。 */
@@ -164,7 +186,8 @@ export function captureAndUpload(req: CaptureRequest): Promise<boolean> {
164186

165187
const res = await fetch(`/api/sliderule/apps/${encodeURIComponent(appId)}/preview`, {
166188
method: "POST",
167-
headers: { "Content-Type": "image/png" },
189+
// 按 blob 实际类型报,不写死——toBlob 不认 webp 时会静默回落成 PNG。
190+
headers: { "Content-Type": blob.type || "image/webp" },
168191
body: blob,
169192
});
170193
if (!res.ok) return false;

slide-rule-python/Dockerfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,10 @@ EXPOSE 9700
3636
# IPv4 连接 → 健康检查超时。这类平台把 UVICORN_HOST 设成 0.0.0.0 即可。
3737
# 端口听平台注入的 $PORT,没有回落 9700(compose 设了 PORT=9700)。
3838
# sh -c + exec 保证 uvicorn 是 PID 1、正常收 SIGTERM 优雅退出。
39-
CMD ["sh", "-c", "exec python -m uvicorn app:app --host ${UVICORN_HOST:-::} --port ${PORT:-9700}"]
39+
#
40+
# --timeout-graceful-shutdown:收到 SIGTERM 后最多等在途请求这么久,然后强退
41+
# (2026-08-02 事故后加)。默认是**无限等**——而一趟推演要 6~20 分钟,于是
42+
# 容器停不下来:实测本地 dev:stop 停不掉、端口一直被占,只能手动 kill 端口
43+
# 占用进程。部署时这表现为滚动更新卡住。30s 够让正常请求收尾,又不会被一趟
44+
# 长推演拖住。
45+
CMD ["sh", "-c", "exec python -m uvicorn app:app --host ${UVICORN_HOST:-::} --port ${PORT:-9700} --timeout-graceful-shutdown ${UVICORN_GRACEFUL_TIMEOUT:-30}"]

slide-rule-python/requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ psycopg[binary]>=3.2
3434

3535
# P2b execution tools: code.run runs ONLY inside E2B sandbox (fail-closed without key)
3636
e2b-code-interpreter>=2.8.0
37+
38+
# 缩略图编码(2026-08-02):卡片缩略图从 PNG 换 WebP,实测 805KB → 43KB
39+
# (分辨率不减)。见 services/thumb_image.py。
40+
# 惰性导入 + fail-open:装不上时整条压缩链路静默失效、原样存 PNG,不影响功能。
41+
pillow>=10.4

slide-rule-python/routes/sliderule_full.py

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,9 @@ async def exec_cap(payload: Dict[str, Any], x_internal_key: Optional[str] = Head
585585
return result
586586

587587
@router.post("/drive-turn")
588-
async def drive(payload: Dict[str, Any], x_internal_key: Optional[str] = Header(None)):
588+
# `def` 而不是 `async def`——理由与 /drive-full 那条完全相同(见下面那段长注释):
589+
# drive_reasoning_turn 是同步的重活,写在 async 里会占住事件循环。
590+
def drive(payload: Dict[str, Any], x_internal_key: Optional[str] = Header(None)):
589591
"""Single turn drive (drive_reasoning_turn). Full multi-loop driver authority exposed via /drive-full."""
590592
_auth(x_internal_key)
591593
state = V5SessionState(**payload["state"])
@@ -594,7 +596,29 @@ async def drive(payload: Dict[str, Any], x_internal_key: Optional[str] = Header(
594596
return {"state": new_state.model_dump(), "stateAuthority": STATE_AUTHORITY_PYTHON, "provenance": PROVENANCE_PYTHON_RAG, "backend": PYTHON_BACKEND}
595597

596598
@router.post("/drive-full")
597-
async def drive_full(payload: Dict[str, Any], x_internal_key: Optional[str] = Header(None)):
599+
# ⚠️ 这条路由是 `def` 而不是 `async def`——**故意的,别改回去**(2026-08-02)。
600+
#
601+
# 事故形状:只要有人在推演,整个服务就不响应,连 /api/health 都超时。原因是
602+
# drive_full_v5_session 是**同步**函数(v5_full_driver.py),一趟推演 6~20 分钟
603+
# (实测本次 5 个话题 374~1190s),此前它被直接写在 `async def` 里,于是整段
604+
# 跑在事件循环那条线程上——单 worker 下,事件循环被占住 = 全站失联。
605+
#
606+
# 修法用的是 FastAPI 官方口径,不是自己发明的:
607+
# "When you declare a path operation function with normal `def` instead of
608+
# `async def`, it is run in an external threadpool that is then awaited,
609+
# instead of being called directly (as it would block the server)."
610+
# —— fastapi.tiangolo.com/async/
611+
# 底层是 Starlette 的 run_in_threadpool → anyio.to_thread.run_sync。
612+
#
613+
# 为什么不用 asyncio.to_thread 手动包一层:能达到同样效果,但要在每个调用点
614+
# 各写一遍、且容易漏(本文件里 LLM/RAG 那几条就是这么写的,而这两条当初正是
615+
# 漏掉的)。函数签名去掉 async 是**声明式**的,漏不掉。
616+
#
617+
# 代价(记下来,别踩):线程池默认只有 40 个槽(anyio 的
618+
# current_default_thread_limiter),而每趟推演会占住一个槽十几分钟。同时在跑的
619+
# 推演超过 40 个就会开始排队——真到那一天,正确的解法是把推演挪进任务队列,
620+
# 而不是把这个数字调大。
621+
def drive_full(payload: Dict[str, Any], x_internal_key: Optional[str] = Header(None)):
598622
"""Python driver authority for multiple capability loops until stop condition (coverage/empty picks/max_loops).
599623
Wires drive_full_v5_session as the visible full-path multi-loop API (PYTHON_AUTHORITY).
600624
Real userText (user instruction) is forwarded so it drives pick/orchestrate/execute/artifacts/GCOV/phase.
@@ -1432,20 +1456,34 @@ async def list_generated_apps(
14321456
offset: int = 0,
14331457
x_internal_key: Optional[str] = Header(None),
14341458
):
1435-
"""应用画廊列表——默认每个应用只出最新版,摘要不含大模型载荷。"""
1459+
"""应用画廊列表——默认每个应用只出最新版,摘要不含大模型载荷。
1460+
1461+
**同步的库调用必须 to_thread**(2026-08-02 线上事故修复)。这几条路由是
1462+
`async def`,而 uvicorn 只跑一个 worker、一个事件循环;直接在协程里调同步
1463+
的 SQLAlchemy,一次慢查询就把整个事件循环冻住——`/api/health` 与
1464+
`/api/agent-loop/health` 跟着一起超时,"存储层拖垮主链路"的承诺当场作废。
1465+
这正是切回 Neon 后线上观察到的形状。
1466+
1467+
本文件里 LLM/RAG/附件解析那几条早就是这么写的(见 asyncio.to_thread 的
1468+
其它调用点),app store 这几条是漏网的。
1469+
"""
14361470
_auth(x_internal_key)
14371471
from services import app_store
14381472

1439-
return {"apps": app_store.list_apps(limit=limit, offset=offset)}
1473+
apps = await asyncio.to_thread(app_store.list_apps, limit=limit, offset=offset)
1474+
return {"apps": apps}
14401475

14411476

14421477
@router.get("/apps/{app_id}")
14431478
async def get_generated_app(app_id: str, x_internal_key: Optional[str] = Header(None)):
1444-
"""取一个生成应用的完整记录(含 model_json,可直接重开渲染)。"""
1479+
"""取一个生成应用的完整记录(含 model_json,可直接重开渲染)。
1480+
1481+
同步库调用走 to_thread,理由见 list_generated_apps。
1482+
"""
14451483
_auth(x_internal_key)
14461484
from services import app_store
14471485

1448-
record = app_store.get_app(app_id)
1486+
record = await asyncio.to_thread(app_store.get_app, app_id)
14491487
if record is None:
14501488
raise HTTPException(404, "app not found")
14511489
return record
@@ -1454,6 +1492,7 @@ async def get_generated_app(app_id: str, x_internal_key: Optional[str] = Header(
14541492
@router.get("/apps/{app_id}/preview")
14551493
async def get_generated_app_preview(
14561494
app_id: str,
1495+
request: Request,
14571496
source: Optional[str] = None,
14581497
x_internal_key: Optional[str] = Header(None),
14591498
):
@@ -1480,14 +1519,30 @@ async def get_generated_app_preview(
14801519
_auth(x_internal_key)
14811520
from services import app_store
14821521

1483-
png = app_store.get_app_preview_png(
1484-
app_id, source=app_store.normalize_preview_source(source) if source else None
1522+
# 同步库调用走 to_thread,理由见 list_generated_apps。
1523+
data = await asyncio.to_thread(
1524+
app_store.get_app_preview_png,
1525+
app_id,
1526+
source=app_store.normalize_preview_source(source) if source else None,
14851527
)
1486-
if not png:
1528+
if not data:
14871529
raise HTTPException(404, "preview not found")
1530+
1531+
from services.thumb_image import client_accepts_webp, sniff_media_type, to_png
1532+
1533+
# 按内容报 Content-Type,不写死 image/png:库里存量是 PNG、新写入是 WebP,
1534+
# 报错类型浏览器可能拒绝渲染,CDN 也会缓存错。
1535+
media = sniff_media_type(data)
1536+
# Accept 头协商(thumbor / imgproxy / Next.js Image 同款做法):不认 WebP 的
1537+
# 客户端现场转回 PNG。WebP 覆盖率 97%+,这条是给极老客户端和抓取工具留的,
1538+
# 不是主路径。转不动就原样给——宁可让客户端拿到一张它可能不认的图,
1539+
# 也不要 500。
1540+
if media == "image/webp" and not client_accepts_webp(request.headers.get("accept")):
1541+
data = await asyncio.to_thread(to_png, data)
1542+
media = sniff_media_type(data)
14881543
return Response(
1489-
content=png,
1490-
media_type="image/png",
1544+
content=data,
1545+
media_type=media,
14911546
headers={"Cache-Control": "public, max-age=31536000, immutable"},
14921547
)
14931548

@@ -1497,9 +1552,22 @@ async def get_generated_app_preview(
14971552
#: 这是一张缩略图,几 MB 的东西进来只会把列表接口和 Neon 拖慢。
14981553
_MAX_SHOT_BYTES = 3 * 1024 * 1024
14991554

1500-
#: PNG 的魔数。只认 PNG:取图路由是按 image/png 回的,别的格式进来会让
1501-
#: 浏览器拿到一个声称是 PNG 的 JPEG。
1502-
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
1555+
#: 允许回传的图片格式。采集端出的是 WebP(见 client/src/lib/thumb-capture.ts),
1556+
#: 但 canvas.toBlob 对不认识的 type 会静默回落成 PNG,所以两种都得收。
1557+
#: 仍然只收这两种:取图路由按内容嗅探报 Content-Type,收进来一个声称是图片的
1558+
#: 任意字节流只会让浏览器拿到一张渲染不出来的东西。
1559+
_ALLOWED_SHOT_MAGIC = ("image/png", "image/webp")
1560+
1561+
1562+
def _looks_like_image(data: bytes) -> bool:
1563+
"""真的按魔数验一遍。
1564+
1565+
sniff_media_type 认不出时会**兜底返回 image/png**(为了让历史存量能正常
1566+
显示),所以它不能单独用来做入口校验——不然任意字节流都会被判成 PNG 放进来。
1567+
"""
1568+
return data.startswith(b"\x89PNG\r\n\x1a\n") or (
1569+
data[:4] == b"RIFF" and data[8:12] == b"WEBP"
1570+
)
15031571

15041572

15051573
@router.post("/apps/{app_id}/preview")
@@ -1533,20 +1601,23 @@ async def upload_generated_app_shot(
15331601
_auth(x_internal_key)
15341602
from services import app_store
15351603

1536-
if app_store.get_app(app_id) is None:
1604+
# 同步库调用走 to_thread,理由见 list_generated_apps。
1605+
if await asyncio.to_thread(app_store.get_app, app_id) is None:
15371606
raise HTTPException(404, "app not found")
1538-
if app_store.app_has_shot(app_id):
1607+
if await asyncio.to_thread(app_store.app_has_shot, app_id):
15391608
return {"stored": False, "reason": "already_has_shot"}
15401609

15411610
body = await request.body()
15421611
if not body:
15431612
raise HTTPException(400, "empty body")
15441613
if len(body) > _MAX_SHOT_BYTES:
15451614
raise HTTPException(413, "screenshot too large")
1546-
if not body.startswith(_PNG_MAGIC):
1547-
raise HTTPException(415, "expected a PNG")
1615+
from services.thumb_image import sniff_media_type
1616+
1617+
if sniff_media_type(body) not in _ALLOWED_SHOT_MAGIC or not _looks_like_image(body):
1618+
raise HTTPException(415, "expected a PNG or WebP image")
15481619

1549-
stored = app_store.save_app_shot(app_id, body)
1620+
stored = await asyncio.to_thread(app_store.save_app_shot, app_id, body)
15501621
return {"stored": stored, "bytes": len(body)}
15511622

15521623

@@ -1556,7 +1627,8 @@ async def list_generated_app_versions(root_id: str, x_internal_key: Optional[str
15561627
_auth(x_internal_key)
15571628
from services import app_store
15581629

1559-
return {"versions": app_store.list_versions(root_id)}
1630+
versions = await asyncio.to_thread(app_store.list_versions, root_id)
1631+
return {"versions": versions}
15601632

15611633

15621634
@router.post("/apps/{app_id}/fork")
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""把库里存量的 PNG 缩略图重新编码成 WebP(2026-08-02)。
2+
3+
新写入的缩略图已经是 WebP(services/thumb_image),但改动之前落的那些还是 PNG,
4+
每张 805~857KB。这个脚本把它们就地换掉——**分辨率不动,只换编码**。
5+
6+
cd slide-rule-python
7+
.venv/bin/python scripts/thumb_recompress.py --dry-run # 只看能省多少
8+
.venv/bin/python scripts/thumb_recompress.py # 真写
9+
10+
--dry-run 是默认关的反面:默认**只看不写**,要加 --apply 才真改。缩略图是线上
11+
数据,误跑一次没有回头路(原始 PNG 不会另存一份)。
12+
13+
顺带:这个脚本的输出就是压缩比的实测依据。单测里**不**断言压缩比——合成夹具
14+
造不出真实缩略图那种体积,在它上面断言比例是自欺欺人(见 tests/test_thumb_image
15+
里那段说明)。
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import argparse
21+
import base64
22+
import sys
23+
from pathlib import Path
24+
25+
_PY_DIR = Path(__file__).resolve().parent.parent
26+
sys.path.insert(0, str(_PY_DIR))
27+
28+
29+
def _load_env() -> None:
30+
"""按 .env 配好存储后端——脚本跟服务读同一份配置。"""
31+
import os
32+
33+
for path in (_PY_DIR.parent / ".env", _PY_DIR / ".env"):
34+
try:
35+
text = path.read_text(encoding="utf-8")
36+
except OSError:
37+
continue
38+
for line in text.splitlines():
39+
line = line.strip()
40+
if not line or line.startswith("#") or "=" not in line:
41+
continue
42+
k, _, v = line.partition("=")
43+
os.environ.setdefault(k.strip(), v.strip())
44+
45+
46+
def main() -> int:
47+
ap = argparse.ArgumentParser()
48+
ap.add_argument("--apply", action="store_true", help="真的写回去(默认只看不写)")
49+
ap.add_argument("--limit", type=int, default=0, help="只处理前 N 条(调试用)")
50+
args = ap.parse_args()
51+
52+
_load_env()
53+
from services import app_store as store
54+
from services.thumb_image import sniff_media_type, to_webp
55+
56+
backend = store.get_backend()
57+
tags = backend.preview_sources()
58+
ids = sorted(tags)
59+
if args.limit:
60+
ids = ids[: args.limit]
61+
if not ids:
62+
print("库里没有缩略图,无事可做")
63+
return 0
64+
65+
print(f"{'app_id':<10}{'来源':<7}{'原始':>10}{'WebP':>10}{'倍数':>7}")
66+
total_before = total_after = 0
67+
changed = 0
68+
for app_id in ids:
69+
for src in store.PREVIEW_SOURCE_PRIORITY:
70+
b64 = backend.get_preview(app_id, source=src)
71+
if not b64:
72+
continue
73+
raw = base64.b64decode(b64)
74+
if sniff_media_type(raw) == "image/webp":
75+
continue # 已经换过了
76+
out = to_webp(raw)
77+
total_before += len(raw)
78+
total_after += len(out)
79+
ratio = len(raw) / max(1, len(out))
80+
print(
81+
f"{app_id[:8]:<10}{src:<7}{len(raw)//1024:>8}KB{len(out)//1024:>8}KB"
82+
f"{ratio:>6.1f}x"
83+
)
84+
if args.apply and len(out) < len(raw):
85+
backend.save_preview(
86+
app_id, base64.b64encode(out).decode("ascii"), source=src
87+
)
88+
changed += 1
89+
90+
if total_before == 0:
91+
print("\n所有缩略图都已经是 WebP,无事可做")
92+
return 0
93+
mb = lambda n: n / 1024 / 1024 # noqa: E731
94+
print(
95+
f"\n合计 {mb(total_before):.1f}MB → {mb(total_after):.1f}MB"
96+
f"(小 {total_before / max(1, total_after):.1f} 倍)"
97+
)
98+
# 5Mbps = 640KB/s,应用中心首屏要把所有卡的图都拉一遍
99+
for label, n in (("改前", total_before), ("改后", total_after)):
100+
print(f" {label}:5Mbps 出口上冷启动一次 ≈ {n / 1024 / 640:.1f}s")
101+
if args.apply:
102+
print(f"\n已写回 {changed} 张")
103+
else:
104+
print("\n(--dry-run 模式,什么都没写。要真改加 --apply)")
105+
return 0
106+
107+
108+
if __name__ == "__main__":
109+
raise SystemExit(main())

0 commit comments

Comments
 (0)