fix(chat): resolve Auto-route failover event-loop freeze (#812)

Root cause: ensure_structured_column() executed a synchronous blocking
engine.connect().execute(SELECT information_schema.columns ...) inside the
async request path (_build_chat_history / _maybe_compress_history), freezing
the uvicorn event loop and hanging all endpoints (~200s, recovered only by
docker restart).

Fix:
- chat_context.py: remove per-request ensure_structured_column() calls; run
  once idempotently at app startup (main.py lifespan). Use CURRENT_DATABASE()
  for PostgreSQL compatibility.
- main.py: add startup-only ensure_structured_column() hook (try/except).
- llm_client.py: trust_env=False + bounded httpx.Timeout/wait_for so broken
  endpoints fail fast instead of hanging the event loop.
- chat_service.py: run media generation in a background daemon thread with its
  own event loop (_launch_media_task_bg) so SSE end does not cancel image/video
  tasks (#811); remove TEMP faulthandler diagnostic.

Safe-align: persist accumulated live container changes (admin/*, knowledge,
market, models, config, observability, mcp_client, platform_knowledge,
im_bot_platform, chat_media, capability_registry, requirements) and add
workflow_templates.

Verified: live failover test (model-006 -> conn-refused) returns in ~1.9s with
concurrent login 0.83s/200; event-loop responsiveness restored (sub-second).
This commit is contained in:
QihuAI Design committed 2026-07-26 00:35:35 +08:00
1 parent 394a7786e0
commit a2504bce34
27 files changed
+253 -59

No files matched your search

@@ -284,7 +284,7 @@ async def claim_achievement_reward(
# Mark reward as claimed
_execute_raw(db,
"UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
"UPDATE user_achievements SET reward_claimed = true, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id})
return {"code": 0, "message": "奖励领取成功"}
@@ -316,7 +316,7 @@ async def create_dashboard(
config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]')
if is_default:
db.execute(text("UPDATE analytics_dashboards SET is_default=0"))
db.execute(text("UPDATE analytics_dashboards SET is_default=false"))
db.execute(text("""
INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by)
@@ -332,7 +332,7 @@ async def create_dashboard(
@router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除仪表盘"""
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id})
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=false"), {"id": dashboard_id})
db.commit()
return {"code": 200, "data": {"message": "仪表盘已删除"}}
@@ -445,7 +445,7 @@ async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
FROM billing_transactions
WHERE transaction_type='recharge' AND status='completed'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND created_at >= NOW() - INTERVAL '7 DAY'
GROUP BY DATE(created_at) ORDER BY d
""")).fetchall()
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
@@ -103,7 +103,7 @@ async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_curre
try:
total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"]
today = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"]
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURRENT_DATE")[0]["cnt"]
success_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"]
failed_count = _execute_raw(db,
+1 -1
View File
@@ -703,7 +703,7 @@ echo "Installation complete!"
{d.get('version', '1.0.0')}
## Installation
Run `bash install.sh` to install.
Run \"bash install.sh\" to install.
## License
A valid license key is required. Contact your administrator for details.
+6 -6
View File
@@ -169,7 +169,7 @@ async def get_im_bot_stats(user=Depends(get_current_user)):
today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0}
# Total routing rules
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt']
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=true"))[0]._mapping['cnt']
return {
"success": True,
@@ -202,7 +202,7 @@ async def get_dashboard(user=Depends(get_current_user)):
SUM(messages_out) as msg_out,
SUM(unique_users) as users
FROM im_bot_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
WHERE stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
GROUP BY stat_date
ORDER BY stat_date
"""))
@@ -217,7 +217,7 @@ async def get_dashboard(user=Depends(get_current_user)):
LEFT JOIN (
SELECT channel_id, SUM(messages_in + messages_out) as msg_count
FROM im_bot_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE stat_date >= CURRENT_DATE - INTERVAL '30 DAY'
GROUP BY channel_id
) s ON c.id = s.channel_id
ORDER BY c.created_at DESC
@@ -357,7 +357,7 @@ async def get_channel_detail(channel_id: str, user=Depends(get_current_user)):
SELECT stat_date, messages_in, messages_out, unique_users, error_count
FROM im_bot_daily_stats
WHERE channel_id = :cid
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
ORDER BY stat_date
"""), {"cid": channel_id})
channel['stats_7d'] = _rows_to_dict(stats)
@@ -428,7 +428,7 @@ async def activate_channel(channel_id: str, user=Depends(get_current_user)):
db = SessionLocal()
try:
_execute_raw(db, text(
"UPDATE channels SET is_active=1, status='connected' WHERE id=:cid"
"UPDATE channels SET is_active=true, status='connected' WHERE id=:cid"
), {"cid": channel_id})
return {"success": True, "message": "Channel activated"}
except Exception as e:
@@ -443,7 +443,7 @@ async def deactivate_channel(channel_id: str, user=Depends(get_current_user)):
db = SessionLocal()
try:
_execute_raw(db, text(
"UPDATE channels SET is_active=0, status='disconnected' WHERE id=:cid"
"UPDATE channels SET is_active=false, status='disconnected' WHERE id=:cid"
), {"cid": channel_id})
return {"success": True, "message": "Channel deactivated"}
except Exception as e:
@@ -442,7 +442,7 @@ def revoke_license(
raise HTTPException(404, "License not found")
_execute_raw(db, text(
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid"
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = false WHERE id = :lid"
), {"reason": reason, "lid": license_id})
return {"code": 0, "message": "License revoked"}
@@ -623,7 +623,7 @@ def get_usage_stats(db: Session = Depends(get_db)):
"SELECT COUNT(DISTINCT user_hash) FROM usage_reports"
))
today_reports = _execute_raw(db, text(
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()"
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURRENT_DATE"
))
by_type = _execute_raw(db, text(
"SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type"
+2 -4
View File
@@ -1251,7 +1251,7 @@ def _build_system_prompt(
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 \"\"\" 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
@@ -2536,7 +2536,6 @@ def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
"""
ensure_structured_column()
summary_block = None
if include_summary:
try:
@@ -2707,7 +2706,7 @@ def ensure_structured_column():
with engine.connect() as conn:
exists = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name='conversation_summaries' "
"WHERE table_schema = CURRENT_DATABASE() AND table_name='conversation_summaries' "
"AND column_name='structured_memory'"
)).scalar()
if not exists:
@@ -2832,7 +2831,6 @@ async def _maybe_compress_history(conv_id):
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
"""
ensure_structured_column()
sdb = None
try:
sdb = SessionLocal()
+3 -2
View File
@@ -3,7 +3,8 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from sqlalchemy import and_, desc, asc, func, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from pydantic import BaseModel, Field
from typing import Optional
import uuid
@@ -893,7 +894,7 @@ def _build_media_route(db, capability: str, preferred_id: str = None):
others = db.query(_AM).filter(
_AM.is_enabled == True,
func.json_contains(_AM.capabilities, f'"{capability}"'),
_AM.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
).order_by(_AM.is_default.desc(), _AM.created_at.asc()).all()
for m in others:
_add(m)
+15 -3
View File
@@ -47,6 +47,7 @@ import json
import asyncio
import threading
@@ -7927,7 +7928,7 @@ async def _send_message_core(
asyncio.create_task(
_launch_media_task_bg(ai_msg.id, ai_model.id, req.content, current_user.id)
@@ -7943,7 +7944,6 @@ async def _send_message_core(
_generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id)
@@ -7959,7 +7959,6 @@ async def _send_message_core(
)
@@ -8547,3 +8546,16 @@ def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_co
# 后台线程独立事件循环运行媒体生成,确保 SSE 响应结束后任务不被 uviorn 取消,
# 避免图片/视频永远停留在「生成中…」。_generate_media_task 内部自管 DB 会话。
def _launch_media_task_bg(ai_msg_id, ai_model_id, prompt, user_id):
def _run():
try:
asyncio.run(_generate_media_task(ai_msg_id, ai_model_id, prompt, user_id))
except Exception:
pass
_t = threading.Thread(target=_run, daemon=True)
_t.start()
+7 -7
View File
@@ -109,20 +109,20 @@ def _ensure_table(db):
"""确保 im_bot_platform_account 表存在(首次调用时创建)。"""
db.execute(text("""
CREATE TABLE IF NOT EXISTS im_bot_platform_account (
id INT AUTO_INCREMENT PRIMARY KEY,
id SERIAL PRIMARY KEY,
user_id VARCHAR(64) DEFAULT NULL,
platform VARCHAR(32) NOT NULL,
status VARCHAR(32) DEFAULT 'disconnected',
creds MEDIUMTEXT NULL,
creds TEXT NULL,
send_url VARCHAR(512) NULL,
api_base VARCHAR(512) NULL,
bot_token VARCHAR(512) NULL,
webhook_token VARCHAR(64) NULL,
last_error MEDIUMTEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_platform (user_id, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
last_error TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, platform)
)
"""))
db.commit()
+2 -2
View File
@@ -502,8 +502,8 @@ def _parse_llm_graph_response(content: str):
except json.JSONDecodeError:
pass
# 方式2: 提取```json ... ```代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', content)
# 方式2: 提取\"\"\"json ... \"\"\"代码块
json_match = re.search(r'\"\"\"(?:json)?\s*([\s\S]*?)\"\"\"', content)
if json_match:
try:
result = json.loads(json_match.group(1))
+2 -2
View File
@@ -59,7 +59,7 @@ def _sanitize_skill_content(text: str) -> str:
if "<script" in low and t.count("<div") > 3:
return ""
# 接受携带任一 markdown / skill 标记的内容
markers = ("#", "---", "name:", "description:", "```", "##")
markers = ("#", "---", "name:", "description:", "\"\"\"", "##")
if not any(m in t for m in markers):
return ""
return t
@@ -79,7 +79,7 @@ def _extract_skill_from_html(html: str, base_url: str) -> str:
"""从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
proxy = _skill_http_proxy()
# 1) fenced code block
fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE)
fences = re.findall(r"\"\"\"(?:markdown|md|yaml|json)?\s*\n(.*?)\"\"\"", html, re.DOTALL | re.IGNORECASE)
for f in fences:
if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
return f.strip()