perf: 同步 DB 操作迁移到 asyncio.to_thread,避免阻塞事件循环

failover/stream_telemetry/recording/stream 中的同步 DB 操作(commit/execute/query)
会阻塞 asyncio 事件循环,导致 Hub PING 心跳无法发送、worker idle timeout 断连。
将这些操作包装到 asyncio.to_thread() 中执行。

同时提取 Alembic 迁移脚本中重复的幂等性辅助函数到 alembic/helpers.py,
用批量查询缓存替代逐条 information_schema 查询,backfill SQL 合并为 LEFT JOIN。

新增 failover 中客户端断连的快速终止路径,避免继续无意义的重试。
This commit is contained in:
fawney19
2026-03-09 11:49:03 +08:00
parent 4dbfeb87b8
commit 0bddc7965b
10 changed files with 923 additions and 741 deletions

171
alembic/helpers.py Normal file
View File

@@ -0,0 +1,171 @@
"""Shared idempotent helpers for Alembic migrations.
All metadata lookups are batched: one query loads an entire table's column info
or all FK delete rules, then results are cached for the migration's lifetime.
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
import sqlalchemy as sa
from alembic import op
# ---------------------------------------------------------------------------
# Batch metadata cache
# ---------------------------------------------------------------------------
class _SchemaCache:
"""Lazy, per-migration cache for information_schema lookups.
Call ``load_columns(tables)`` / ``load_fk_rules(tables)`` once at the top
of ``upgrade()`` or ``downgrade()`` to prime the cache. Subsequent
``column_exists`` / ``column_type`` / ``fk_ondelete`` calls are pure
dict lookups -- zero extra DB round-trips.
"""
def __init__(self) -> None:
# {table_name: {column_name: data_type}}
self._columns: dict[str, dict[str, str]] = {}
# {(table_name, constraint_name): delete_rule}
self._fk_rules: dict[tuple[str, str], str] = {}
self._fk_loaded_tables: set[str] = set()
# -- loaders (one query per call) --------------------------------------
def load_columns(self, tables: list[str]) -> None:
"""Fetch column names + data types for *tables* in one query."""
need = [t for t in tables if t not in self._columns]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT table_name, column_name, data_type "
"FROM information_schema.columns "
"WHERE table_name = ANY(:tables)"
),
{"tables": need},
).fetchall()
# Initialise even empty tables so we don't re-query
for t in need:
self._columns.setdefault(t, {})
for table, col, dtype in rows:
self._columns[table][col] = dtype
def load_fk_rules(self, tables: list[str]) -> None:
"""Fetch FK delete rules for *tables* in one query."""
need = [t for t in tables if t not in self._fk_loaded_tables]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
"WHERE tc.table_name = ANY(:tables)"
),
{"tables": need},
).fetchall()
for table, name, rule in rows:
self._fk_rules[(table, name)] = rule
self._fk_loaded_tables.update(need)
# -- lookups (pure dict, zero DB) --------------------------------------
def column_exists(self, table: str, column: str) -> bool:
return column in self._columns.get(table, {})
def column_type(self, table: str, column: str) -> str | None:
return self._columns.get(table, {}).get(column)
def is_numeric(self, table: str, column: str) -> bool:
return self.column_type(table, column) == "numeric"
def fk_ondelete(self, table: str, constraint: str) -> str | None:
return self._fk_rules.get((table, constraint))
def invalidate_columns(self, table: str) -> None:
"""Force re-load on next load_columns() for *table* (after ADD/DROP COLUMN)."""
self._columns.pop(table, None)
def new_cache() -> _SchemaCache:
"""Create a fresh schema cache for a single migration run."""
return _SchemaCache()
# ---------------------------------------------------------------------------
# Idempotent DDL helpers
# ---------------------------------------------------------------------------
def replace_fk_if_needed(
cache: _SchemaCache,
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
current = cache.fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current:
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name,
table_name,
ref_table,
local_cols,
remote_cols,
ondelete=desired_ondelete,
)
def index_exists(index_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": index_name},
)
return result.scalar() is not None
# ---------------------------------------------------------------------------
# Batch ALTER TYPE helper
# ---------------------------------------------------------------------------
def batch_alter_type(
cache: _SchemaCache,
columns: list[tuple[str, str, bool, str | None]],
cast_suffix: str,
type_fn: Callable[[str], str],
) -> None:
"""Group columns by table and issue ONE ``ALTER TABLE`` per table.
Skips columns that don't exist. Each tuple is
``(table_name, column_name, nullable, server_default)``.
"""
by_table: dict[str, list[tuple[str, str]]] = defaultdict(list)
for table, col, _nullable, _default in columns:
if not cache.column_exists(table, col):
continue
by_table[table].append((col, type_fn(col)))
bind = op.get_bind()
for table, col_types in by_table.items():
parts = [
f"ALTER COLUMN {col} TYPE {target} USING {col}::{cast_suffix}"
for col, target in col_types
]
if parts:
bind.execute(sa.text(f"ALTER TABLE {table} " + ", ".join(parts)))

View File

@@ -5,137 +5,93 @@ Revises: 2d932114930d
Create Date: 2026-03-08 03:48:49.622091+00:00 Create Date: 2026-03-08 03:48:49.622091+00:00
""" """
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from alembic.helpers import new_cache, replace_fk_if_needed
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '45b118150a78' revision = "45b118150a78"
down_revision = '2d932114930d' down_revision = "2d932114930d"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
_TABLES = ["usage", "stats_user_daily", "stats_daily_api_key"]
# ---------------------------------------------------------------------------
# Idempotent helpers
# ---------------------------------------------------------------------------
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
return result.scalar() is not None
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE table_name = :table AND constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
return result.scalar() is not None
def _fk_ondelete(table_name: str, constraint_name: str) -> str | None:
"""Return the ON DELETE action for a foreign key, or None if it doesn't exist."""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
"WHERE tc.table_name = :table AND tc.constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
row = result.first()
return row[0] if row else None
def _replace_fk_if_needed(
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
current = _fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return # already correct
if current:
op.drop_constraint(constraint_name, table_name, type_='foreignkey')
op.create_foreign_key(
constraint_name, table_name, ref_table,
local_cols, remote_cols, ondelete=desired_ondelete,
)
def upgrade() -> None: def upgrade() -> None:
c = new_cache()
c.load_columns(_TABLES)
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
# --- Usage: add name snapshot columns --- # --- Usage: add name snapshot columns ---
if not _column_exists('usage', 'username'): if not c.column_exists("usage", "username"):
op.add_column('usage', sa.Column('username', sa.String(100), nullable=True, op.add_column(
comment='用户名快照')) "usage", sa.Column("username", sa.String(100), nullable=True, comment="用户名快照")
if not _column_exists('usage', 'api_key_name'): )
op.add_column('usage', sa.Column('api_key_name', sa.String(200), nullable=True, if not c.column_exists("usage", "api_key_name"):
comment='API Key 名称快照')) op.add_column(
"usage",
sa.Column("api_key_name", sa.String(200), nullable=True, comment="API Key 名称快照"),
)
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot --- # --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
_replace_fk_if_needed( replace_fk_if_needed(
'stats_user_daily_user_id_fkey', 'stats_user_daily', c,
'users', ['user_id'], ['id'], 'SET NULL', "stats_user_daily_user_id_fkey",
"stats_user_daily",
"users",
["user_id"],
["id"],
"SET NULL",
) )
op.alter_column('stats_user_daily', 'user_id', existing_type=sa.String(36), nullable=True) op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=True)
if not _column_exists('stats_user_daily', 'username'): if not c.column_exists("stats_user_daily", "username"):
op.add_column('stats_user_daily', sa.Column('username', sa.String(100), nullable=True, op.add_column(
comment='用户名快照(删除用户后仍可追溯)')) "stats_user_daily",
sa.Column(
"username",
sa.String(100),
nullable=True,
comment="用户名快照(删除用户后仍可追溯)",
),
)
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot --- # --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
_replace_fk_if_needed( replace_fk_if_needed(
'stats_daily_api_key_api_key_id_fkey', 'stats_daily_api_key', c,
'api_keys', ['api_key_id'], ['id'], 'SET NULL', "stats_daily_api_key_api_key_id_fkey",
"stats_daily_api_key",
"api_keys",
["api_key_id"],
["id"],
"SET NULL",
) )
op.alter_column('stats_daily_api_key', 'api_key_id', existing_type=sa.String(36), op.alter_column("stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=True)
nullable=True) if not c.column_exists("stats_daily_api_key", "api_key_name"):
if not _column_exists('stats_daily_api_key', 'api_key_name'): op.add_column(
op.add_column('stats_daily_api_key', sa.Column('api_key_name', sa.String(200), "stats_daily_api_key",
nullable=True, sa.Column(
comment='API Key 名称快照(删除 Key 后仍可追溯)')) "api_key_name",
sa.String(200),
nullable=True,
comment="API Key 名称快照(删除 Key 后仍可追溯)",
),
)
# --- Backfill: populate snapshots from existing FK joins --- # --- Backfill: populate snapshots from existing FK joins ---
# Single UPDATE per table using LEFT JOINs to fill both columns in one pass. # One LEFT JOIN UPDATE per table covers all rows regardless of which FK is present.
# (WHERE ... IS NULL makes these inherently idempotent)
op.execute(""" op.execute("""
UPDATE usage u UPDATE usage u
SET username = COALESCE(u.username, usr.username), SET username = COALESCE(u.username, usr.username),
api_key_name = COALESCE(u.api_key_name, ak.name) api_key_name = COALESCE(u.api_key_name, ak.name)
FROM users usr, api_keys ak FROM usage u2
WHERE usr.id = u.user_id LEFT JOIN users usr ON usr.id = u2.user_id
AND ak.id = u.api_key_id LEFT JOIN api_keys ak ON ak.id = u2.api_key_id
WHERE u.id = u2.id
AND (u.username IS NULL OR u.api_key_name IS NULL) AND (u.username IS NULL OR u.api_key_name IS NULL)
""") """)
# Catch rows that have user_id but no api_key_id (or vice versa)
op.execute("""
UPDATE usage u
SET username = usr.username
FROM users usr
WHERE u.user_id = usr.id AND u.username IS NULL
""")
op.execute("""
UPDATE usage u
SET api_key_name = ak.name
FROM api_keys ak
WHERE u.api_key_id = ak.id AND u.api_key_name IS NULL
""")
op.execute(""" op.execute("""
UPDATE stats_user_daily s UPDATE stats_user_daily s
SET username = usr.username SET username = usr.username
@@ -151,27 +107,42 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
c = new_cache()
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
# --- Remove snapshot columns --- # --- Remove snapshot columns ---
if _column_exists('stats_daily_api_key', 'api_key_name'): if c.column_exists("stats_daily_api_key", "api_key_name"):
op.drop_column('stats_daily_api_key', 'api_key_name') op.drop_column("stats_daily_api_key", "api_key_name")
if _column_exists('stats_user_daily', 'username'): if c.column_exists("stats_user_daily", "username"):
op.drop_column('stats_user_daily', 'username') op.drop_column("stats_user_daily", "username")
if _column_exists('usage', 'api_key_name'): if c.column_exists("usage", "api_key_name"):
op.drop_column('usage', 'api_key_name') op.drop_column("usage", "api_key_name")
if _column_exists('usage', 'username'): if c.column_exists("usage", "username"):
op.drop_column('usage', 'username') op.drop_column("usage", "username")
# --- StatsDailyApiKey: SET NULL -> CASCADE --- # --- StatsDailyApiKey: SET NULL -> CASCADE ---
_replace_fk_if_needed( replace_fk_if_needed(
'stats_daily_api_key_api_key_id_fkey', 'stats_daily_api_key', c,
'api_keys', ['api_key_id'], ['id'], 'CASCADE', "stats_daily_api_key_api_key_id_fkey",
"stats_daily_api_key",
"api_keys",
["api_key_id"],
["id"],
"CASCADE",
)
op.alter_column(
"stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=False
) )
op.alter_column('stats_daily_api_key', 'api_key_id', existing_type=sa.String(36),
nullable=False)
# --- StatsUserDaily: SET NULL -> CASCADE --- # --- StatsUserDaily: SET NULL -> CASCADE ---
_replace_fk_if_needed( replace_fk_if_needed(
'stats_user_daily_user_id_fkey', 'stats_user_daily', c,
'users', ['user_id'], ['id'], 'CASCADE', "stats_user_daily_user_id_fkey",
"stats_user_daily",
"users",
["user_id"],
["id"],
"CASCADE",
) )
op.alter_column('stats_user_daily', 'user_id', existing_type=sa.String(36), nullable=False) op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=False)

View File

@@ -6,89 +6,32 @@ Create Date: 2026-03-08 12:15:00.000000+00:00
""" """
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from alembic.helpers import new_cache, replace_fk_if_needed
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "13a4c8f6d9e0" revision = "13a4c8f6d9e0"
down_revision = "45b118150a78" down_revision = "45b118150a78"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
_TABLES = ["request_candidates", "video_tasks"]
# ---------------------------------------------------------------------------
# Idempotent helpers
# ---------------------------------------------------------------------------
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
return result.scalar() is not None
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE table_name = :table AND constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
return result.scalar() is not None
def _fk_ondelete(table_name: str, constraint_name: str) -> str | None:
"""Return the ON DELETE action for a foreign key, or None if it doesn't exist."""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
"WHERE tc.table_name = :table AND tc.constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
row = result.first()
return row[0] if row else None
def _replace_fk_if_needed(
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
current = _fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return # already correct
if current:
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name, table_name, ref_table,
local_cols, remote_cols, ondelete=desired_ondelete,
)
def upgrade() -> None: def upgrade() -> None:
c = new_cache()
c.load_columns(_TABLES)
c.load_fk_rules(_TABLES)
# --- request_candidates: add snapshot columns --- # --- request_candidates: add snapshot columns ---
if not _column_exists("request_candidates", "username"): if not c.column_exists("request_candidates", "username"):
op.add_column( op.add_column(
"request_candidates", "request_candidates",
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"), sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
) )
if not _column_exists("request_candidates", "api_key_name"): if not c.column_exists("request_candidates", "api_key_name"):
op.add_column( op.add_column(
"request_candidates", "request_candidates",
sa.Column( sa.Column(
@@ -100,22 +43,32 @@ def upgrade() -> None:
) )
# --- request_candidates: CASCADE -> SET NULL --- # --- request_candidates: CASCADE -> SET NULL ---
_replace_fk_if_needed( replace_fk_if_needed(
"request_candidates_user_id_fkey", "request_candidates", c,
"users", ["user_id"], ["id"], "SET NULL", "request_candidates_user_id_fkey",
"request_candidates",
"users",
["user_id"],
["id"],
"SET NULL",
) )
_replace_fk_if_needed( replace_fk_if_needed(
"request_candidates_api_key_id_fkey", "request_candidates", c,
"api_keys", ["api_key_id"], ["id"], "SET NULL", "request_candidates_api_key_id_fkey",
"request_candidates",
"api_keys",
["api_key_id"],
["id"],
"SET NULL",
) )
# --- video_tasks: add snapshot columns --- # --- video_tasks: add snapshot columns ---
if not _column_exists("video_tasks", "username"): if not c.column_exists("video_tasks", "username"):
op.add_column( op.add_column(
"video_tasks", "video_tasks",
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"), sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
) )
if not _column_exists("video_tasks", "api_key_name"): if not c.column_exists("video_tasks", "api_key_name"):
op.add_column( op.add_column(
"video_tasks", "video_tasks",
sa.Column( sa.Column(
@@ -128,106 +81,100 @@ def upgrade() -> None:
# --- video_tasks: CASCADE -> SET NULL, user_id nullable --- # --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True) op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
_replace_fk_if_needed( replace_fk_if_needed(
"video_tasks_user_id_fkey", "video_tasks", c,
"users", ["user_id"], ["id"], "SET NULL", "video_tasks_user_id_fkey",
"video_tasks",
"users",
["user_id"],
["id"],
"SET NULL",
) )
_replace_fk_if_needed( replace_fk_if_needed(
"video_tasks_api_key_id_fkey", "video_tasks", c,
"api_keys", ["api_key_id"], ["id"], "SET NULL", "video_tasks_api_key_id_fkey",
"video_tasks",
"api_keys",
["api_key_id"],
["id"],
"SET NULL",
) )
# --- Backfill: populate snapshots from existing FK joins --- # --- Backfill: populate snapshots from existing FK joins ---
# Single UPDATE per table using JOINs to fill both columns in one pass. # One LEFT JOIN UPDATE per table covers all rows regardless of which FK is present.
# (WHERE ... IS NULL makes these inherently idempotent) op.execute("""
# request_candidates: fill both columns where both FKs exist
op.execute(
"""
UPDATE request_candidates rc UPDATE request_candidates rc
SET username = COALESCE(rc.username, usr.username), SET username = COALESCE(rc.username, usr.username),
api_key_name = COALESCE(rc.api_key_name, ak.name) api_key_name = COALESCE(rc.api_key_name, ak.name)
FROM users usr, api_keys ak FROM request_candidates rc2
WHERE usr.id = rc.user_id LEFT JOIN users usr ON usr.id = rc2.user_id
AND ak.id = rc.api_key_id LEFT JOIN api_keys ak ON ak.id = rc2.api_key_id
WHERE rc.id = rc2.id
AND (rc.username IS NULL OR rc.api_key_name IS NULL) AND (rc.username IS NULL OR rc.api_key_name IS NULL)
""" """)
)
# Catch rows with only user_id or only api_key_id
op.execute(
"""
UPDATE request_candidates rc
SET username = usr.username
FROM users usr
WHERE usr.id = rc.user_id AND rc.username IS NULL
"""
)
op.execute(
"""
UPDATE request_candidates rc
SET api_key_name = ak.name
FROM api_keys ak
WHERE ak.id = rc.api_key_id AND rc.api_key_name IS NULL
"""
)
# video_tasks: fill both columns where both FKs exist op.execute("""
op.execute(
"""
UPDATE video_tasks vt UPDATE video_tasks vt
SET username = COALESCE(vt.username, usr.username), SET username = COALESCE(vt.username, usr.username),
api_key_name = COALESCE(vt.api_key_name, ak.name) api_key_name = COALESCE(vt.api_key_name, ak.name)
FROM users usr, api_keys ak FROM video_tasks vt2
WHERE usr.id = vt.user_id LEFT JOIN users usr ON usr.id = vt2.user_id
AND ak.id = vt.api_key_id LEFT JOIN api_keys ak ON ak.id = vt2.api_key_id
WHERE vt.id = vt2.id
AND (vt.username IS NULL OR vt.api_key_name IS NULL) AND (vt.username IS NULL OR vt.api_key_name IS NULL)
""" """)
)
# Catch rows with only user_id or only api_key_id
op.execute(
"""
UPDATE video_tasks vt
SET username = usr.username
FROM users usr
WHERE usr.id = vt.user_id AND vt.username IS NULL
"""
)
op.execute(
"""
UPDATE video_tasks vt
SET api_key_name = ak.name
FROM api_keys ak
WHERE ak.id = vt.api_key_id AND vt.api_key_name IS NULL
"""
)
def downgrade() -> None: def downgrade() -> None:
c = new_cache()
c.load_columns(_TABLES)
c.load_fk_rules(_TABLES)
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL --- # --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
_replace_fk_if_needed( replace_fk_if_needed(
"video_tasks_api_key_id_fkey", "video_tasks", c,
"api_keys", ["api_key_id"], ["id"], "NO ACTION", "video_tasks_api_key_id_fkey",
"video_tasks",
"api_keys",
["api_key_id"],
["id"],
"NO ACTION",
) )
_replace_fk_if_needed( replace_fk_if_needed(
"video_tasks_user_id_fkey", "video_tasks", c,
"users", ["user_id"], ["id"], "NO ACTION", "video_tasks_user_id_fkey",
"video_tasks",
"users",
["user_id"],
["id"],
"NO ACTION",
) )
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=False) op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=False)
if _column_exists("video_tasks", "api_key_name"): if c.column_exists("video_tasks", "api_key_name"):
op.drop_column("video_tasks", "api_key_name") op.drop_column("video_tasks", "api_key_name")
if _column_exists("video_tasks", "username"): if c.column_exists("video_tasks", "username"):
op.drop_column("video_tasks", "username") op.drop_column("video_tasks", "username")
# --- request_candidates: SET NULL -> CASCADE --- # --- request_candidates: SET NULL -> CASCADE ---
_replace_fk_if_needed( replace_fk_if_needed(
"request_candidates_api_key_id_fkey", "request_candidates", c,
"api_keys", ["api_key_id"], ["id"], "CASCADE", "request_candidates_api_key_id_fkey",
"request_candidates",
"api_keys",
["api_key_id"],
["id"],
"CASCADE",
) )
_replace_fk_if_needed( replace_fk_if_needed(
"request_candidates_user_id_fkey", "request_candidates", c,
"users", ["user_id"], ["id"], "CASCADE", "request_candidates_user_id_fkey",
"request_candidates",
"users",
["user_id"],
["id"],
"CASCADE",
) )
if _column_exists("request_candidates", "api_key_name"): if c.column_exists("request_candidates", "api_key_name"):
op.drop_column("request_candidates", "api_key_name") op.drop_column("request_candidates", "api_key_name")
if _column_exists("request_candidates", "username"): if c.column_exists("request_candidates", "username"):
op.drop_column("request_candidates", "username") op.drop_column("request_candidates", "username")

View File

@@ -6,9 +6,8 @@ Create Date: 2026-03-08 15:30:00.000000+00:00
""" """
import sqlalchemy as sa
from alembic import op from alembic import op
from alembic.helpers import batch_alter_type, index_exists, new_cache
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "2053ab8ed764" revision = "2053ab8ed764"
@@ -77,44 +76,7 @@ _COST_COLUMNS: list[tuple[str, str, bool, str | None]] = [
("stats_user_daily", "total_cost", False, "0.0"), ("stats_user_daily", "total_cost", False, "0.0"),
] ]
# rate_multiplier uses a smaller precision _ALL_TABLES = list({t for t, *_ in _COST_COLUMNS})
_RATE_MULTIPLIER_TYPE = sa.Numeric(10, 6)
_COST_TYPE = sa.Numeric(20, 8)
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
return result.scalar() is not None
def _index_exists(index_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": index_name},
)
return result.scalar() is not None
def _is_numeric_type(table_name: str, column_name: str) -> bool:
"""Check if a column is already numeric type (not float/double precision)."""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
data_type = result.scalar()
return data_type == "numeric"
def _type_spec(col: str) -> str: def _type_spec(col: str) -> str:
@@ -122,50 +84,20 @@ def _type_spec(col: str) -> str:
return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)" return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)"
def _batch_alter_type(
columns: list[tuple[str, str, bool, str | None]],
cast_suffix: str,
type_fn=None,
) -> None:
"""Group columns by table and issue ONE ALTER TABLE per table.
This avoids rewriting the same table N times (once per column).
"""
from collections import defaultdict
by_table: dict[str, list[tuple[str, str, bool, str | None]]] = defaultdict(list)
for table, col, nullable, default in columns:
if not _column_exists(table, col):
continue
by_table[table].append((table, col, nullable, default))
bind = op.get_bind()
for table, cols in by_table.items():
# Build a single ALTER TABLE with multiple ALTER COLUMN clauses
parts: list[str] = []
for _t, col, _nullable, _default in cols:
target_type = type_fn(col) if type_fn else _type_spec(col)
parts.append(
f"ALTER COLUMN {col} TYPE {target_type} USING {col}::{cast_suffix}"
)
if not parts:
continue
sql = f"ALTER TABLE {table} " + ", ".join(parts)
bind.execute(sa.text(sql))
def upgrade() -> None: def upgrade() -> None:
c = new_cache()
c.load_columns(_ALL_TABLES)
# -- 1. cost fields: Float -> Numeric (batched per table) # -- 1. cost fields: Float -> Numeric (batched per table)
# Filter out columns that are already numeric
cols_to_convert = [ cols_to_convert = [
(t, c, n, d) (t, col, n, d)
for t, c, n, d in _COST_COLUMNS for t, col, n, d in _COST_COLUMNS
if _column_exists(t, c) and not _is_numeric_type(t, c) if c.column_exists(t, col) and not c.is_numeric(t, col)
] ]
_batch_alter_type(cols_to_convert, cast_suffix="numeric", type_fn=_type_spec) batch_alter_type(c, cols_to_convert, cast_suffix="numeric", type_fn=_type_spec)
# -- 2. provider_api_keys composite index # -- 2. provider_api_keys composite index
if not _index_exists("idx_provider_api_keys_provider_active"): if not index_exists("idx_provider_api_keys_provider_active"):
op.create_index( op.create_index(
"idx_provider_api_keys_provider_active", "idx_provider_api_keys_provider_active",
"provider_api_keys", "provider_api_keys",
@@ -175,19 +107,23 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# -- 2. drop composite index # -- 2. drop composite index
if _index_exists("idx_provider_api_keys_provider_active"): if index_exists("idx_provider_api_keys_provider_active"):
op.drop_index( op.drop_index(
"idx_provider_api_keys_provider_active", "idx_provider_api_keys_provider_active",
table_name="provider_api_keys", table_name="provider_api_keys",
) )
# -- 1. Numeric -> Float (batched per table) # -- 1. Numeric -> Float (batched per table)
c = new_cache()
c.load_columns(_ALL_TABLES)
cols_to_revert = [ cols_to_revert = [
(t, c, n, d) (t, col, n, d)
for t, c, n, d in _COST_COLUMNS for t, col, n, d in _COST_COLUMNS
if _column_exists(t, c) and _is_numeric_type(t, c) if c.column_exists(t, col) and c.is_numeric(t, col)
] ]
_batch_alter_type( batch_alter_type(
c,
cols_to_revert, cols_to_revert,
cast_suffix="double precision", cast_suffix="double precision",
type_fn=lambda _col: "DOUBLE PRECISION", type_fn=lambda _col: "DOUBLE PRECISION",

View File

@@ -6,9 +6,7 @@ Create Date: 2026-03-09 01:00:00.000000+00:00
""" """
import sqlalchemy as sa from alembic.helpers import new_cache, replace_fk_if_needed
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "d7649c1f8e21" revision = "d7649c1f8e21"
@@ -20,47 +18,11 @@ _TABLE = "video_tasks"
_FK_NAME = "video_tasks_key_id_fkey" _FK_NAME = "video_tasks_key_id_fkey"
def _fk_ondelete(table_name: str, constraint_name: str) -> str | None:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
"WHERE tc.table_name = :table AND tc.constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
row = result.first()
return row[0] if row else None
def _replace_fk_if_needed(
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
current = _fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current:
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name,
table_name,
ref_table,
local_cols,
remote_cols,
ondelete=desired_ondelete,
)
def upgrade() -> None: def upgrade() -> None:
_replace_fk_if_needed( c = new_cache()
c.load_fk_rules([_TABLE])
replace_fk_if_needed(
c,
_FK_NAME, _FK_NAME,
_TABLE, _TABLE,
"provider_api_keys", "provider_api_keys",
@@ -71,7 +33,10 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
_replace_fk_if_needed( c = new_cache()
c.load_fk_rules([_TABLE])
replace_fk_if_needed(
c,
_FK_NAME, _FK_NAME,
_TABLE, _TABLE,
"provider_api_keys", "provider_api_keys",

View File

@@ -533,18 +533,21 @@ class BaseMessageHandler:
target_request_id = request_id or self.request_id target_request_id = request_id or self.request_id
def _sync_update() -> None:
db_gen = get_db()
db = next(db_gen)
try:
UsageService.update_usage_status(
db=db,
request_id=target_request_id,
status="streaming",
)
finally:
db.close()
async def _do_update() -> None: async def _do_update() -> None:
try: try:
db_gen = get_db() await asyncio.to_thread(_sync_update)
db = next(db_gen)
try:
UsageService.update_usage_status(
db=db,
request_id=target_request_id,
status="streaming",
)
finally:
db.close()
except Exception as e: except Exception as e:
logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}") logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}")
@@ -585,29 +588,36 @@ class BaseMessageHandler:
f"ctx.provider_name={ctx.provider_name}, ctx.provider_id={ctx.provider_id}" f"ctx.provider_name={ctx.provider_name}, ctx.provider_id={ctx.provider_id}"
) )
# Capture mutable ctx attrs before handing off to thread
provider_request_headers = ctx.provider_request_headers or None
provider_request_body = ctx.provider_request_body
def _sync_update() -> None:
db_gen = get_db()
db = next(db_gen)
try:
UsageService.update_usage_status(
db=db,
request_id=target_request_id,
status="streaming",
provider=provider,
target_model=target_model,
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id,
first_byte_time_ms=first_byte_time_ms,
api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
provider_request_headers=provider_request_headers,
provider_request_body=provider_request_body,
)
finally:
db.close()
async def _do_update() -> None: async def _do_update() -> None:
try: try:
db_gen = get_db() await asyncio.to_thread(_sync_update)
db = next(db_gen)
try:
UsageService.update_usage_status(
db=db,
request_id=target_request_id,
status="streaming",
provider=provider,
target_model=target_model,
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id,
first_byte_time_ms=first_byte_time_ms,
api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
provider_request_headers=ctx.provider_request_headers or None,
provider_request_body=ctx.provider_request_body,
)
finally:
db.close()
except Exception as e: except Exception as e:
logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}") logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}")

View File

@@ -392,56 +392,67 @@ class StreamTelemetryRecorder:
if not ctx.attempt_id: if not ctx.attempt_id:
return return
from src.services.request.candidate import RequestCandidateService # Capture all needed ctx attrs before handing off to thread
attempt_id = ctx.attempt_id
is_success = ctx.is_success()
is_disconnected = ctx.is_client_disconnected()
status_code = ctx.status_code
data_count = ctx.data_count
rectified = ctx.rectified
proxy_info = ctx.proxy_info
first_byte_time_ms_val = ctx.first_byte_time_ms
upstream_response = ctx.upstream_response
error_message = ctx.error_message
extra_data: dict[str, Any] = { def _sync() -> None:
"stream_completed": ctx.is_success(), from src.services.request.candidate import RequestCandidateService
"data_count": ctx.data_count,
}
if ctx.rectified:
extra_data["rectified"] = True
if ctx.proxy_info:
extra_data["proxy"] = ctx.proxy_info
if ctx.first_byte_time_ms is not None:
# 计算候选自身的 TTFB
first_byte_time_ms = RequestCandidateService.calculate_candidate_ttfb(
db=db,
candidate_id=ctx.attempt_id,
request_start_time=request_start_time,
global_first_byte_time_ms=ctx.first_byte_time_ms,
)
extra_data["first_byte_time_ms"] = first_byte_time_ms
if ctx.is_success(): extra_data: dict[str, Any] = {
RequestCandidateService.mark_candidate_success( "stream_completed": is_success,
db=db, "data_count": data_count,
candidate_id=ctx.attempt_id, }
status_code=ctx.status_code, if rectified:
latency_ms=response_time_ms, extra_data["rectified"] = True
extra_data=extra_data, if proxy_info:
) extra_data["proxy"] = proxy_info
elif ctx.is_client_disconnected(): if first_byte_time_ms_val is not None:
RequestCandidateService.mark_candidate_cancelled( candidate_ttfb = RequestCandidateService.calculate_candidate_ttfb(
db=db, db=db,
candidate_id=ctx.attempt_id, candidate_id=attempt_id,
status_code=ctx.status_code, request_start_time=request_start_time,
latency_ms=response_time_ms, global_first_byte_time_ms=first_byte_time_ms_val,
extra_data=extra_data, )
) extra_data["first_byte_time_ms"] = candidate_ttfb
else:
# 请求链路追踪使用 upstream_response原始响应回退到 error_message友好消息 if is_success:
trace_error_message = ( RequestCandidateService.mark_candidate_success(
ctx.upstream_response or ctx.error_message or f"HTTP {ctx.status_code}" db=db,
) candidate_id=attempt_id,
RequestCandidateService.mark_candidate_failed( status_code=status_code,
db=db, latency_ms=response_time_ms,
candidate_id=ctx.attempt_id, extra_data=extra_data,
error_type="stream_error", )
error_message=trace_error_message, elif is_disconnected:
status_code=ctx.status_code, RequestCandidateService.mark_candidate_cancelled(
latency_ms=response_time_ms, db=db,
extra_data=extra_data, candidate_id=attempt_id,
) status_code=status_code,
latency_ms=response_time_ms,
extra_data=extra_data,
)
else:
trace_error_message = upstream_response or error_message or f"HTTP {status_code}"
RequestCandidateService.mark_candidate_failed(
db=db,
candidate_id=attempt_id,
error_type="stream_error",
error_message=trace_error_message,
status_code=status_code,
latency_ms=response_time_ms,
extra_data=extra_data,
)
await asyncio.to_thread(_sync)
async def _update_usage_status_on_error( async def _update_usage_status_on_error(
self, self,
@@ -474,10 +485,12 @@ class StreamTelemetryRecorder:
error_message: str | None = None, error_message: str | None = None,
) -> None: ) -> None:
"""直接更新 Usage 表的状态字段""" """直接更新 Usage 表的状态字段"""
try: request_id = self.request_id
def _sync() -> None:
from src.models.database import Usage from src.models.database import Usage
usage = db.query(Usage).filter(Usage.request_id == self.request_id).first() usage = db.query(Usage).filter(Usage.request_id == request_id).first()
if usage: if usage:
setattr(usage, "status", status) setattr(usage, "status", status)
setattr(usage, "status_code", status_code) setattr(usage, "status_code", status_code)
@@ -485,7 +498,10 @@ class StreamTelemetryRecorder:
if error_message: if error_message:
setattr(usage, "error_message", error_message) setattr(usage, "error_message", error_message)
db.commit() db.commit()
logger.debug(f"[{self.request_id}] Usage 状态已更新: {status}") logger.debug(f"[{request_id}] Usage 状态已更新: {status}")
try:
await asyncio.to_thread(_sync)
except Exception as e: except Exception as e:
logger.error(f"[{self.request_id}] 直接更新 Usage 状态失败: {e}") logger.error(f"[{self.request_id}] 直接更新 Usage 状态失败: {e}")

View File

@@ -24,6 +24,17 @@ from .policy import FailoverAction, RetryMode, RetryPolicy, SkipPolicy
from .recorder import CandidateRecorder from .recorder import CandidateRecorder
from .schema import CandidateKey from .schema import CandidateKey
_DISCONNECT_EXCEPTION_NAMES = frozenset({"ClientDisconnectedException"})
def _is_client_disconnected(exc: Exception) -> bool:
"""检查异常(或其 cause)是否为客户端断连,避免循环 import。"""
for obj in (exc, getattr(exc, "cause", None)):
if obj is not None and type(obj).__name__ in _DISCONNECT_EXCEPTION_NAMES:
return True
return False
_SENSITIVE_PATTERN = re.compile( _SENSITIVE_PATTERN = re.compile(
r"(api[_-]?key|token|bearer|authorization)[=:\s]+\S+", r"(api[_-]?key|token|bearer|authorization)[=:\s]+\S+",
re.IGNORECASE, re.IGNORECASE,
@@ -66,6 +77,14 @@ class FailoverEngine:
self._error_classifier = error_classifier or ErrorClassifier(db=db) self._error_classifier = error_classifier or ErrorClassifier(db=db)
self._recorder = recorder or CandidateRecorder(db) self._recorder = recorder or CandidateRecorder(db)
async def _db_op(self, func: Callable[[], Any]) -> Any:
"""将同步 DB 操作放到线程池执行,避免阻塞 asyncio 事件循环。
当事件循环被同步 db.commit() / db.execute() 阻塞时,
Hub PING 心跳无法发送,导致 worker idle timeout 断连,整个服务不可用。
"""
return await asyncio.to_thread(func)
@staticmethod @staticmethod
def _collect_error_messages(error: Exception | None) -> str: def _collect_error_messages(error: Exception | None) -> str:
if error is None: if error is None:
@@ -192,7 +211,7 @@ class FailoverEngine:
except Exception: except Exception:
return False return False
def _mark_remaining_cancelled( async def _mark_remaining_cancelled(
self, self,
*, *,
candidate_record_map: dict[tuple[int, int], str] | None, candidate_record_map: dict[tuple[int, int], str] | None,
@@ -204,8 +223,8 @@ class FailoverEngine:
if not candidate_record_map: if not candidate_record_map:
return return
now = datetime.now(timezone.utc) # Pre-compute outside thread (reads ProviderCandidate attrs that may not be thread-safe)
updated = False record_ids: list[str] = []
for candidate_idx, cand in enumerate(candidates): for candidate_idx, cand in enumerate(candidates):
if candidate_idx < from_candidate_idx: if candidate_idx < from_candidate_idx:
continue continue
@@ -214,11 +233,18 @@ class FailoverEngine:
if candidate_idx == from_candidate_idx and retry_idx < from_retry_idx: if candidate_idx == from_candidate_idx and retry_idx < from_retry_idx:
continue continue
record_id = candidate_record_map.get((candidate_idx, retry_idx)) record_id = candidate_record_map.get((candidate_idx, retry_idx))
if not record_id: if record_id:
continue record_ids.append(record_id)
if not record_ids:
return
def _do() -> None:
now = datetime.now(timezone.utc)
for rid in record_ids:
self.db.execute( self.db.execute(
update(RequestCandidate) update(RequestCandidate)
.where(RequestCandidate.id == record_id) .where(RequestCandidate.id == rid)
.where(RequestCandidate.status.in_(["available", "pending"])) .where(RequestCandidate.status.in_(["available", "pending"]))
.values( .values(
status="cancelled", status="cancelled",
@@ -227,11 +253,10 @@ class FailoverEngine:
finished_at=now, finished_at=now,
) )
) )
updated = True
if updated:
self.db.commit() self.db.commit()
await self._db_op(_do)
def _append_cancelled_fallback_candidate_keys( def _append_cancelled_fallback_candidate_keys(
self, self,
*, *,
@@ -295,7 +320,7 @@ class FailoverEngine:
from_candidate_idx, from_candidate_idx,
from_retry_idx, from_retry_idx,
) )
self._mark_remaining_cancelled( await self._mark_remaining_cancelled(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidates=candidates, candidates=candidates,
from_candidate_idx=from_candidate_idx, from_candidate_idx=from_candidate_idx,
@@ -322,6 +347,46 @@ class FailoverEngine:
attempt_count=attempt_count, attempt_count=attempt_count,
) )
async def _build_disconnected_result(
self,
*,
candidate_record_map: dict[tuple[int, int], str] | None,
candidate_keys_fallback: list[CandidateKey],
candidates: list[ProviderCandidate],
from_candidate_idx: int,
from_retry_idx: int,
retry_policy: RetryPolicy,
request_id: str | None,
attempt_count: int,
) -> ExecutionResult:
"""客户端已断连,立即终止故障转移并返回结果。"""
await self._mark_remaining_cancelled(
candidate_record_map=candidate_record_map,
candidates=candidates,
from_candidate_idx=from_candidate_idx,
from_retry_idx=from_retry_idx,
retry_policy=retry_policy,
)
self._append_cancelled_fallback_candidate_keys(
fallback=candidate_keys_fallback,
candidates=candidates,
from_candidate_idx=from_candidate_idx,
from_retry_idx=from_retry_idx,
retry_policy=retry_policy,
)
return ExecutionResult(
success=False,
error_type="ClientDisconnected",
error_message="client_disconnected",
last_status_code=499,
candidate_keys=self._get_candidate_keys(
request_id=request_id,
fallback=candidate_keys_fallback,
candidates=candidates,
),
attempt_count=attempt_count,
)
async def execute( async def execute(
self, self,
*, *,
@@ -391,7 +456,7 @@ class FailoverEngine:
if should_skip: if should_skip:
# PRE_EXPAND: mark all retry slots skipped. # PRE_EXPAND: mark all retry slots skipped.
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_candidate_skipped( await self._mark_candidate_skipped(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidate_index=candidate_index, candidate_index=candidate_index,
candidate=candidate, candidate=candidate,
@@ -490,17 +555,9 @@ class FailoverEngine:
candidate, candidate_index, retry_index, record_id, attempt_count, max_attempts candidate, candidate_index, retry_index, record_id, attempt_count, max_attempts
) )
# Mark pending # Mark pending + commit BEFORE await
now = datetime.now(timezone.utc) # (avoid holding DB connections during slow upstream calls)
if record_id: await self._mark_pending_and_commit(record_id)
self._update_record(
record_id,
status="pending",
started_at=now,
)
# Commit BEFORE await (avoid holding DB connections during slow upstream calls)
self._commit_before_await()
try: try:
attempt_result = await self._execute_attempt( attempt_result = await self._execute_attempt(
@@ -512,7 +569,7 @@ class FailoverEngine:
# PRE_EXPAND: mark unused slots after request ends (success) # PRE_EXPAND: mark unused slots after request ends (success)
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_remaining_slots_unused( await self._mark_remaining_slots_unused(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidates=candidates, candidates=candidates,
success_candidate_idx=candidate_index, success_candidate_idx=candidate_index,
@@ -542,7 +599,7 @@ class FailoverEngine:
except StreamProbeError as exc: except StreamProbeError as exc:
last_status_code = exc.http_status last_status_code = exc.http_status
self._record_attempt_failure(record_id, exc, exc.http_status) await self._record_attempt_failure(record_id, exc, exc.http_status)
action = FailoverAction.CONTINUE action = FailoverAction.CONTINUE
consecutive_failures += 1 consecutive_failures += 1
await self._apply_retry_pacing( await self._apply_retry_pacing(
@@ -553,6 +610,19 @@ class FailoverEngine:
) )
except Exception as exc: except Exception as exc:
if _is_client_disconnected(exc):
await self._record_attempt_failure(record_id, exc, 499)
return await self._build_disconnected_result(
candidate_record_map=candidate_record_map,
candidate_keys_fallback=candidate_keys_fallback,
candidates=candidates,
from_candidate_idx=candidate_index,
from_retry_idx=retry_index + 1,
retry_policy=retry_policy,
request_id=request_id,
attempt_count=attempt_count,
)
outcome = await self._handle_attempt_error( outcome = await self._handle_attempt_error(
exc, exc,
candidate=candidate, candidate=candidate,
@@ -587,7 +657,7 @@ class FailoverEngine:
if action == FailoverAction.CONTINUE: if action == FailoverAction.CONTINUE:
# PRE_EXPAND: if we break early, mark remaining retries of this candidate unused. # PRE_EXPAND: if we break early, mark remaining retries of this candidate unused.
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_candidate_remaining_retries_unused( await self._mark_candidate_remaining_retries_unused(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidate_idx=candidate_index, candidate_idx=candidate_index,
from_retry_idx=retry_index + 1, from_retry_idx=retry_index + 1,
@@ -603,7 +673,7 @@ class FailoverEngine:
# exhausted: PRE_EXPAND should not leave 'available' records behind # exhausted: PRE_EXPAND should not leave 'available' records behind
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_all_remaining_available_unused(candidate_record_map) await self._mark_all_remaining_available_unused(candidate_record_map)
return ExecutionResult( return ExecutionResult(
success=False, success=False,
@@ -670,7 +740,7 @@ class FailoverEngine:
or "pool_skipped" or "pool_skipped"
) )
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_retry_indices_status( await self._mark_retry_indices_status(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidate_idx=candidate_index, candidate_idx=candidate_index,
retry_indices=range( retry_indices=range(
@@ -749,15 +819,7 @@ class FailoverEngine:
max_attempts, max_attempts,
) )
now = datetime.now(timezone.utc) await self._mark_pending_and_commit(record_id)
if record_id:
self._update_record(
record_id,
status="pending",
started_at=now,
)
self._commit_before_await()
try: try:
attempt_result = await self._execute_attempt( attempt_result = await self._execute_attempt(
@@ -768,7 +830,7 @@ class FailoverEngine:
last_status_code = int(getattr(attempt_result, "http_status", 0) or 0) last_status_code = int(getattr(attempt_result, "http_status", 0) or 0)
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_remaining_slots_unused( await self._mark_remaining_slots_unused(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidates=candidates, candidates=candidates,
success_candidate_idx=candidate_index, success_candidate_idx=candidate_index,
@@ -803,7 +865,7 @@ class FailoverEngine:
except StreamProbeError as exc: except StreamProbeError as exc:
last_status_code = exc.http_status last_status_code = exc.http_status
self._record_attempt_failure(record_id, exc, exc.http_status) await self._record_attempt_failure(record_id, exc, exc.http_status)
action = FailoverAction.CONTINUE action = FailoverAction.CONTINUE
consecutive_failures += 1 consecutive_failures += 1
await self._apply_retry_pacing( await self._apply_retry_pacing(
@@ -814,6 +876,24 @@ class FailoverEngine:
) )
except Exception as exc: except Exception as exc:
if _is_client_disconnected(exc):
await self._record_attempt_failure(record_id, exc, 499)
return (
await self._build_disconnected_result(
candidate_record_map=candidate_record_map,
candidate_keys_fallback=candidate_keys_fallback,
candidates=candidates,
from_candidate_idx=candidate_index,
from_retry_idx=composite_retry_index + 1,
retry_policy=retry_policy,
request_id=request_id,
attempt_count=attempt_count,
),
attempt_count,
consecutive_failures,
499,
)
outcome = await self._handle_pool_attempt_error( outcome = await self._handle_pool_attempt_error(
exc, exc,
candidate=candidate, candidate=candidate,
@@ -843,7 +923,7 @@ class FailoverEngine:
# max_retries_for_key may have been shrunk by error handler; # max_retries_for_key may have been shrunk by error handler;
# mark unused up to the *original* retry_slots_per_key to cover # mark unused up to the *original* retry_slots_per_key to cover
# all pre-created records. # all pre-created records.
self._mark_retry_indices_status( await self._mark_retry_indices_status(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidate_idx=candidate_index, candidate_idx=candidate_index,
retry_indices=range( retry_indices=range(
@@ -862,7 +942,7 @@ class FailoverEngine:
# should not terminate the entire request because other providers may still succeed. # should not terminate the entire request because other providers may still succeed.
# When handler_used=True, TaskService raises directly for true STOP semantics. # When handler_used=True, TaskService raises directly for true STOP semantics.
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_candidate_remaining_retries_unused( await self._mark_candidate_remaining_retries_unused(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidate_idx=candidate_index, candidate_idx=candidate_index,
from_retry_idx=composite_retry_index + 1, from_retry_idx=composite_retry_index + 1,
@@ -959,7 +1039,7 @@ class FailoverEngine:
candidate, is_success=True, response_text=body_text candidate, is_success=True, response_text=body_text
) )
if rule_action == FailoverAction.CONTINUE: if rule_action == FailoverAction.CONTINUE:
self._record_attempt_failure( await self._record_attempt_failure(
record_id, record_id,
Exception("success_failover_pattern matched"), Exception("success_failover_pattern matched"),
200, 200,
@@ -969,43 +1049,55 @@ class FailoverEngine:
http_status=200, http_status=200,
) )
self._record_attempt_success(record_id, attempt_result) await self._record_attempt_success(record_id, attempt_result)
return attempt_result return attempt_result
def _record_attempt_success(self, record_id: str | None, attempt_result: AttemptResult) -> None: async def _record_attempt_success(
self, record_id: str | None, attempt_result: AttemptResult
) -> None:
"""Mark attempt record as success/streaming.""" """Mark attempt record as success/streaming."""
if not record_id: if not record_id:
return return
if attempt_result.kind == AttemptKind.STREAM:
self._update_record(
record_id,
status="streaming",
status_code=attempt_result.http_status,
)
else:
self._update_record(
record_id,
status="success",
status_code=attempt_result.http_status,
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
def _record_attempt_failure( def _do() -> None:
if attempt_result.kind == AttemptKind.STREAM:
self._update_record(
record_id,
status="streaming",
status_code=attempt_result.http_status,
)
else:
self._update_record(
record_id,
status="success",
status_code=attempt_result.http_status,
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
await self._db_op(_do)
async def _record_attempt_failure(
self, record_id: str | None, exc: Exception, status_code: int | None = None self, record_id: str | None, exc: Exception, status_code: int | None = None
) -> None: ) -> None:
"""Mark attempt record as failed.""" """Mark attempt record as failed."""
if not record_id: if not record_id:
return return
self._update_record( error_type = type(exc).__name__
record_id, error_message = self._sanitize(str(exc))
status="failed",
status_code=status_code, def _do() -> None:
error_type=type(exc).__name__, self._update_record(
error_message=self._sanitize(str(exc)), record_id,
finished_at=datetime.now(timezone.utc), status="failed",
) status_code=status_code,
self.db.commit() error_type=error_type,
error_message=error_message,
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
await self._db_op(_do)
async def _handle_attempt_error( async def _handle_attempt_error(
self, self,
@@ -1046,7 +1138,7 @@ class FailoverEngine:
if outcome.action == FailoverAction.STOP: if outcome.action == FailoverAction.STOP:
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_remaining_slots_unused( await self._mark_remaining_slots_unused(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidates=candidates, candidates=candidates,
success_candidate_idx=candidate_index, success_candidate_idx=candidate_index,
@@ -1120,7 +1212,7 @@ class FailoverEngine:
last_status_code = int(getattr(exc, "status_code", 0) or 0) or int( last_status_code = int(getattr(exc, "status_code", 0) or 0) or int(
getattr(exc, "http_status", 0) or 0 getattr(exc, "http_status", 0) or 0
) )
self._record_attempt_failure(record_id, exc, last_status_code or None) await self._record_attempt_failure(record_id, exc, last_status_code or None)
return AttemptErrorOutcome( return AttemptErrorOutcome(
action=action, action=action,
@@ -1194,13 +1286,33 @@ class FailoverEngine:
) )
return result return result
def _commit_before_await(self) -> None: async def _commit_before_await(self) -> None:
if self.db.in_transaction(): def _do() -> None:
try: if self.db.in_transaction():
self.db.commit() try:
except Exception: self.db.commit()
self.db.rollback() except Exception:
raise self.db.rollback()
raise
await self._db_op(_do)
async def _mark_pending_and_commit(self, record_id: str | None) -> None:
"""Mark record as pending and commit, all within a worker thread."""
def _do() -> None:
if record_id:
self._update_record(
record_id, status="pending", started_at=datetime.now(timezone.utc)
)
if self.db.in_transaction():
try:
self.db.commit()
except Exception:
self.db.rollback()
raise
await self._db_op(_do)
def _update_record(self, record_id: str, /, **values: Any) -> None: def _update_record(self, record_id: str, /, **values: Any) -> None:
self.db.execute( self.db.execute(
@@ -1221,23 +1333,31 @@ class FailoverEngine:
) -> str: ) -> str:
# Create "available" record, then caller will mark pending. # Create "available" record, then caller will mark pending.
extra = self._build_pool_extra_data(candidate) extra = self._build_pool_extra_data(candidate)
row = RequestCandidateService.create_candidate( provider_id = str(candidate.provider.id)
db=self.db, endpoint_id = str(candidate.endpoint.id)
request_id=request_id, key_id = str(candidate.key.id)
candidate_index=candidate_index, is_cached = bool(getattr(candidate, "is_cached", False))
retry_index=retry_index,
user_id=user_id, def _do() -> str:
api_key_id=api_key_id, row = RequestCandidateService.create_candidate(
username=username, db=self.db,
api_key_name=api_key_name, request_id=request_id,
provider_id=str(candidate.provider.id), candidate_index=candidate_index,
endpoint_id=str(candidate.endpoint.id), retry_index=retry_index,
key_id=str(candidate.key.id), user_id=user_id,
status="available", api_key_id=api_key_id,
is_cached=bool(getattr(candidate, "is_cached", False)), username=username,
extra_data=extra, api_key_name=api_key_name,
) provider_id=provider_id,
return str(row.id) endpoint_id=endpoint_id,
key_id=key_id,
status="available",
is_cached=is_cached,
extra_data=extra,
)
return str(row.id)
return await self._db_op(_do)
async def _create_skipped_record( async def _create_skipped_record(
self, self,
@@ -1253,27 +1373,35 @@ class FailoverEngine:
skip_reason: str | None, skip_reason: str | None,
) -> str: ) -> str:
extra = self._build_pool_extra_data(candidate) extra = self._build_pool_extra_data(candidate)
row = RequestCandidateService.create_candidate( provider_id = str(candidate.provider.id)
db=self.db, endpoint_id = str(candidate.endpoint.id)
request_id=request_id, key_id = str(candidate.key.id)
candidate_index=candidate_index, is_cached = bool(getattr(candidate, "is_cached", False))
retry_index=retry_index,
user_id=user_id, def _do() -> str:
api_key_id=api_key_id, row = RequestCandidateService.create_candidate(
username=username, db=self.db,
api_key_name=api_key_name, request_id=request_id,
provider_id=str(candidate.provider.id), candidate_index=candidate_index,
endpoint_id=str(candidate.endpoint.id), retry_index=retry_index,
key_id=str(candidate.key.id), user_id=user_id,
status="skipped", api_key_id=api_key_id,
skip_reason=skip_reason, username=username,
is_cached=bool(getattr(candidate, "is_cached", False)), api_key_name=api_key_name,
extra_data=extra, provider_id=provider_id,
) endpoint_id=endpoint_id,
# ensure visible for subsequent recorder reads key_id=key_id,
if self.db.in_transaction(): status="skipped",
self.db.commit() skip_reason=skip_reason,
return str(row.id) is_cached=is_cached,
extra_data=extra,
)
# ensure visible for subsequent recorder reads
if self.db.in_transaction():
self.db.commit()
return str(row.id)
return await self._db_op(_do)
def _build_pool_extra_data(self, candidate: ProviderCandidate) -> dict[str, Any]: def _build_pool_extra_data(self, candidate: ProviderCandidate) -> dict[str, Any]:
extra: dict[str, Any] = {} extra: dict[str, Any] = {}
@@ -1576,7 +1704,7 @@ class FailoverEngine:
self._sanitize(str(inner)), self._sanitize(str(inner)),
) )
def _mark_candidate_skipped( async def _mark_candidate_skipped(
self, self,
*, *,
candidate_record_map: dict[tuple[int, int], str], candidate_record_map: dict[tuple[int, int], str],
@@ -1586,19 +1714,23 @@ class FailoverEngine:
skip_reason: str | None, skip_reason: str | None,
) -> None: ) -> None:
max_retries = self._get_max_retries(candidate, retry_policy) max_retries = self._get_max_retries(candidate, retry_policy)
now = datetime.now(timezone.utc) record_ids = [
for retry_index in range(max_retries): candidate_record_map[candidate_index, ri]
record_id = candidate_record_map.get((candidate_index, retry_index)) for ri in range(max_retries)
if record_id: if (candidate_index, ri) in candidate_record_map
self._update_record( ]
record_id, if not record_ids:
status="skipped", return
skip_reason=skip_reason,
finished_at=now,
)
self.db.commit()
def _mark_remaining_slots_unused( def _do() -> None:
now = datetime.now(timezone.utc)
for rid in record_ids:
self._update_record(rid, status="skipped", skip_reason=skip_reason, finished_at=now)
self.db.commit()
await self._db_op(_do)
async def _mark_remaining_slots_unused(
self, self,
*, *,
candidate_record_map: dict[tuple[int, int], str], candidate_record_map: dict[tuple[int, int], str],
@@ -1607,7 +1739,7 @@ class FailoverEngine:
success_retry_idx: int, success_retry_idx: int,
retry_policy: RetryPolicy, retry_policy: RetryPolicy,
) -> None: ) -> None:
now = datetime.now(timezone.utc) record_ids: list[str] = []
for candidate_idx, cand in enumerate(candidates): for candidate_idx, cand in enumerate(candidates):
max_retries = self._get_max_retries(cand, retry_policy) max_retries = self._get_max_retries(cand, retry_policy)
for retry_idx in range(max_retries): for retry_idx in range(max_retries):
@@ -1617,14 +1749,20 @@ class FailoverEngine:
continue continue
record_id = candidate_record_map.get((candidate_idx, retry_idx)) record_id = candidate_record_map.get((candidate_idx, retry_idx))
if record_id: if record_id:
self._update_record( record_ids.append(record_id)
record_id,
status="unused",
finished_at=now,
)
self.db.commit()
def _mark_candidate_remaining_retries_unused( if not record_ids:
return
def _do() -> None:
now = datetime.now(timezone.utc)
for rid in record_ids:
self._update_record(rid, status="unused", finished_at=now)
self.db.commit()
await self._db_op(_do)
async def _mark_candidate_remaining_retries_unused(
self, self,
*, *,
candidate_record_map: dict[tuple[int, int], str], candidate_record_map: dict[tuple[int, int], str],
@@ -1633,21 +1771,27 @@ class FailoverEngine:
retry_policy: RetryPolicy, retry_policy: RetryPolicy,
) -> None: ) -> None:
# Only meaningful for PRE_EXPAND. # Only meaningful for PRE_EXPAND.
# We don't have access to candidate object list here, so infer max_retries from map keys.
# Fallback to retry_policy.max_retries.
now = datetime.now(timezone.utc)
# try best-effort upper bound
upper = max( upper = max(
(ri for (ci, ri) in candidate_record_map.keys() if ci == candidate_idx), (ri for (ci, ri) in candidate_record_map.keys() if ci == candidate_idx),
default=retry_policy.max_retries - 1, default=retry_policy.max_retries - 1,
) )
for retry_idx in range(from_retry_idx, upper + 1): record_ids = [
record_id = candidate_record_map.get((candidate_idx, retry_idx)) candidate_record_map[candidate_idx, ri]
if record_id: for ri in range(from_retry_idx, upper + 1)
self._update_record(record_id, status="unused", finished_at=now) if (candidate_idx, ri) in candidate_record_map
self.db.commit() ]
if not record_ids:
return
def _mark_retry_indices_status( def _do() -> None:
now = datetime.now(timezone.utc)
for rid in record_ids:
self._update_record(rid, status="unused", finished_at=now)
self.db.commit()
await self._db_op(_do)
async def _mark_retry_indices_status(
self, self,
*, *,
candidate_record_map: dict[tuple[int, int], str], candidate_record_map: dict[tuple[int, int], str],
@@ -1656,33 +1800,45 @@ class FailoverEngine:
status: str, status: str,
skip_reason: str | None = None, skip_reason: str | None = None,
) -> None: ) -> None:
now = datetime.now(timezone.utc) record_ids = [
for retry_idx in retry_indices: candidate_record_map[candidate_idx, ri]
record_id = candidate_record_map.get((candidate_idx, retry_idx)) for ri in retry_indices
if not record_id: if (candidate_idx, ri) in candidate_record_map
continue ]
values: dict[str, Any] = {"status": status, "finished_at": now} if not record_ids:
if status == "skipped": return
values["skip_reason"] = skip_reason
self._update_record(record_id, **values)
self.db.commit()
def _mark_all_remaining_available_unused( def _do() -> None:
now = datetime.now(timezone.utc)
for rid in record_ids:
values: dict[str, Any] = {"status": status, "finished_at": now}
if status == "skipped":
values["skip_reason"] = skip_reason
self._update_record(rid, **values)
self.db.commit()
await self._db_op(_do)
async def _mark_all_remaining_available_unused(
self, candidate_record_map: dict[tuple[int, int], str] self, candidate_record_map: dict[tuple[int, int], str]
) -> None: ) -> None:
# As a safety net: do not leave available records behind in PRE_EXPAND mode. # As a safety net: do not leave available records behind in PRE_EXPAND mode.
try: ids = list(candidate_record_map.values())
ids = list(candidate_record_map.values()) if not ids:
if not ids: return
return
now = datetime.now(timezone.utc) def _do() -> None:
self.db.execute( try:
update(RequestCandidate) now = datetime.now(timezone.utc)
.where(RequestCandidate.id.in_(ids)) self.db.execute(
.where(RequestCandidate.status == "available") update(RequestCandidate)
.values(status="unused", finished_at=now) .where(RequestCandidate.id.in_(ids))
) .where(RequestCandidate.status == "available")
self.db.commit() .values(status="unused", finished_at=now)
except Exception: )
self.db.rollback() self.db.commit()
raise except Exception:
self.db.rollback()
raise
await self._db_op(_do)

View File

@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid import uuid
from decimal import Decimal
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -421,102 +422,108 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
usage_params, total_cost = await cls._prepare_usage_record(params) usage_params, total_cost = await cls._prepare_usage_record(params)
total_cost = to_money_decimal(total_cost) total_cost = to_money_decimal(total_cost)
# 检查是否已存在相同 request_id 的记录 def _sync_record() -> Usage:
existing_usage = ( """同步 DB 操作: with_for_update + 批量 update + commit"""
db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first() from sqlalchemy import func as sql_func
) from sqlalchemy import update as sa_update
if existing_usage:
if cls._is_usage_finalized(existing_usage): from src.models.database import ApiKey as ApiKeyModel
logger.debug( from src.models.database import GlobalModel
"request_id {} 已完成结算,跳过重复记账 (billing_status={})",
request_id, # 检查是否已存在相同 request_id 的记录
getattr(existing_usage, "billing_status", None), existing_usage = (
) db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
return existing_usage
logger.debug(
f"request_id {request_id} 已存在,更新现有记录 "
f"(status: {existing_usage.status} -> {status})"
) )
cls._update_existing_usage(existing_usage, usage_params, target_model) if existing_usage:
usage = existing_usage if cls._is_usage_finalized(existing_usage):
else: logger.debug(
usage = Usage(**usage_params) "request_id {} 已完成结算,跳过重复记账 (billing_status={})",
db.add(usage) request_id,
getattr(existing_usage, "billing_status", None),
# 确保 user 和 api_key 在会话中
if user and not db.object_session(user):
user = db.merge(user)
if api_key and not db.object_session(api_key):
api_key = db.merge(api_key)
# 使用原子更新避免并发竞态条件
from sqlalchemy import func as sql_func
from sqlalchemy import update
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
if accounted:
# 更新 API 密钥使用量
if api_key:
values: dict[str, Any] = {
"total_requests": ApiKeyModel.total_requests + 1,
"last_used_at": sql_func.now(),
"updated_at": sql_func.now(),
}
if charge_applied:
values["total_cost_usd"] = ApiKeyModel.total_cost_usd + Decimal(
str(to_money_decimal(total_cost))
) )
db.execute(update(ApiKeyModel).where(ApiKeyModel.id == api_key.id).values(**values)) return existing_usage
logger.debug(
f"request_id {request_id} 已存在,更新现有记录 "
f"(status: {existing_usage.status} -> {status})"
)
cls._update_existing_usage(existing_usage, usage_params, target_model)
usage = existing_usage
else:
usage = Usage(**usage_params)
db.add(usage)
# 更新 GlobalModel 使用计数 # 确保 user 和 api_key 在会话中
db.execute( nonlocal user, api_key
update(GlobalModel) if user and not db.object_session(user):
.where(GlobalModel.name == model) user = db.merge(user)
.values(usage_count=GlobalModel.usage_count + 1) if api_key and not db.object_session(api_key):
api_key = db.merge(api_key)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
) )
# 更新用户-模型调用次数计数器 if accounted:
cls._increment_user_model_usage(db, user, model) # 更新 API 密钥使用量
if api_key:
values: dict[str, Any] = {
"total_requests": ApiKeyModel.total_requests + 1,
"last_used_at": sql_func.now(),
"updated_at": sql_func.now(),
}
if charge_applied:
values["total_cost_usd"] = ApiKeyModel.total_cost_usd + Decimal(
str(to_money_decimal(total_cost))
)
db.execute(
sa_update(ApiKeyModel).where(ApiKeyModel.id == api_key.id).values(**values)
)
# 更新 Provider 月度使用量Provider 端真实成本,无论钱包是否扣费) # 更新 GlobalModel 使用计数
if provider_id:
actual_total_cost = Decimal(str(usage_params["actual_total_cost_usd"]))
db.execute( db.execute(
update(Provider) sa_update(GlobalModel)
.where(Provider.id == provider_id) .where(GlobalModel.name == model)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost) .values(usage_count=GlobalModel.usage_count + 1)
) )
# 更新手动代理节点请求计数tunnel 节点由心跳上报,不在此处统计) # 更新用户-模型调用次数计数器
manual_node_id = _extract_manual_proxy_node_id(metadata) cls._increment_user_model_usage(db, user, model)
if manual_node_id:
failed = {manual_node_id: 1} if status == "failed" else None
_increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
dispatch_codex_quota_sync_from_response_headers( # 更新 Provider 月度使用量Provider 端真实成本,无论钱包是否扣费)
provider_api_key_id=provider_api_key_id, if provider_id:
response_headers=response_headers, actual_total_cost = Decimal(str(usage_params["actual_total_cost_usd"]))
db=db, db.execute(
) sa_update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
# 提交事务 # 更新手动代理节点请求计数tunnel 节点由心跳上报,不在此处统计)
try: manual_node_id = _extract_manual_proxy_node_id(metadata)
db.commit() if manual_node_id:
except Exception as e: failed = {manual_node_id: 1} if status == "failed" else None
logger.error("提交使用记录时出错: {}", e) _increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
db.rollback()
raise
return usage dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
response_headers=response_headers,
db=db,
)
# 提交事务
try:
db.commit()
except Exception as e:
logger.error("提交使用记录时出错: {}", e)
db.rollback()
raise
return usage
return await asyncio.to_thread(_sync_record)
@classmethod @classmethod
async def record_usage_with_custom_cost( async def record_usage_with_custom_cost(

View File

@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import re import re
from collections import deque from collections import deque
@@ -518,7 +519,8 @@ class StreamUsageTracker:
# yield 后再更新数据库状态(仅第一个 chunk 时执行) # yield 后再更新数据库状态(仅第一个 chunk 时执行)
if chunk_count == 1 and self.request_id: if chunk_count == 1 and self.request_id:
try: try:
UsageService.update_usage_status( await asyncio.to_thread(
UsageService.update_usage_status,
db=self.db, db=self.db,
request_id=self.request_id, request_id=self.request_id,
status="streaming", status="streaming",
@@ -1022,7 +1024,8 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
# yield 后再更新数据库状态(仅第一个 chunk 时执行) # yield 后再更新数据库状态(仅第一个 chunk 时执行)
if chunk_count == 1 and self.request_id: if chunk_count == 1 and self.request_id:
try: try:
UsageService.update_usage_status( await asyncio.to_thread(
UsageService.update_usage_status,
db=self.db, db=self.db,
request_id=self.request_id, request_id=self.request_id,
status="streaming", status="streaming",