基金转换 T-13:全量回归 + 50 并发压测 + 性能实测补录

交付:新增 tests/test_convert_concurrency.py(9 条 · CONVERT_STRESS 门禁)+ test_convert_service 同键重试 3 条;app/repository/convert_repository.insert_placeholder 改三步法(R-a)、app/service/convert/convert_service.py 幂等判定 pending→202。

修复 2 处并发缺陷(压测暴露,sqlite 不可见):① 阶段一失败后同键重试永久 503(占位朴素 INSERT 撞 uk_group/uk_idem);② 同键竞态子窗口 A 真·双扣 / B 双扣+503(撞 uk_idem 直穿)。

验证:pytest 736 passed / 10 skipped(+5,跑两遍稳定);CONVERT_STRESS=1 并发 9/9;按 SOP 重灌双库复跑零回归;7 个 convert 真库脚本复跑零回归;突变 4 组全部精准命中。50 并发不超卖(成交 20~25)、紧池退避 40/50=80%(不够用)、松池 100%、跨客户 1213 死锁登记待评估;性能端到端 P50 44.2/max 59.8ms、阶段一 P50 9.8/max 16.7ms(未触阈值、未改实测值)。

文档:PRD v0.9.3(§9 第 18 条实测补录)+ 开发计划 §10(10.1~10.5)+ docs/memory/{TODO,MEMORY,FRAMEWORK,ITERATION,2026-09-10}。
This commit is contained in:
2026-09-10 19:46:08 +08:00
parent f1666b78de
commit c56ea8d50e
11 changed files with 1225 additions and 50 deletions
+59 -13
View File
@@ -12,16 +12,20 @@ S2(评审):清理**标记不硬删**——`mark_expired` 置 `status='expi
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
from app.config.settings import settings
from app.utils.db import get_engine
logger = logging.getLogger(__name__)
def _to_bind(value: Any) -> Any:
"""Decimal 转 float 再绑定(sqlite 不支持直接绑定 Decimal;MySQL DECIMAL 列自动收口)。
@@ -45,25 +49,67 @@ class ConvertRepository:
# ---------- 阶段零:占位(uk_idem 兜底) ----------
def insert_placeholder(self, group_id: str, client_request_id: str | None) -> None:
"""阶段零占位:插一行 `status='pending'`。
def insert_placeholder(self, group_id: str, client_request_id: str | None) -> bool:
"""阶段零占位:插一行 `status='pending'`;**已存在则置回 pending**。
返回 **True = 本笔持有该占位,可以继续**;**False = 该 `client_request_id`
已被另一个 `group_id` 占住**(同键并发,本笔必须让路,由调用方回 202)。
`client_request_id` 为 None 时绑 NULL——MySQL / sqlite 的 UNIQUE 约束均允许多个 NULL,
故「无幂等键的请求」可重复占位、互不冲突(uk_idem 仅对非空键兜底)。
`estimated=0`:convert 占位是真实请求,非风控预估单(与 risk_alert 语义区分)。
**三步法(R-a:弃用方言 UPSERT,改「先查再 INSERT 或 UPDATE」)**。
为什么必须容错「行已存在」:阶段一失败(典型是 `LotConflict` 409)时占位已被
`mark_failed` 置为 `failed`,而架构 §8.3 要求调用方带**同一** `client_request_id`
退避重试(≤3 次、100/200/400ms);重试会走「复用原 group_id 重跑」分支再次进入
阶段零——此时 `uk_group` 与 `uk_idem` 都已被那一行占用,朴素的 INSERT 必撞唯一键,
把**可重试的 409 升级成 `IdempotencyUnavailable`(503)**,且是**确定性的**(重试永不成功),
与 errors.py 里「瞬时状态、恢复后重试即可成功」的注释相反。(T-13 压测前置修复)
置回 `pending` 是正确语义:同一 `group_id` 的这一次尝试正在进行中。
到达此处时既有行的状态只可能是 `pending`(上一轮中途崩溃)/ `failed`(阶段一或阶段二失败);
`completed` 已在 `convert_fund` 幂等前置分支返回,不会走到这里。
**为什么还要 catch IntegrityError**:`convert:idem:{cid}` 锁只包住幂等判定
(出块即释放),两笔同键请求可能**都判定为"无占位"**、各自生成了不同的 `group_id`
(T-13 真库实测:8 路并发下偶发);后插入的那笔撞 `uk_idem` → 此前会直穿 503。
这里把它收敛为 False(让路),交由调用方回 202(架构 §9「同键并发 → 202」)。
"""
sql = text(
"""
INSERT INTO risk_convert_detail
(convert_group_id, client_request_id, status, estimated)
VALUES (:gid, :cid_req, :status, 0)
"""
)
with self._engine.begin() as conn:
conn.execute(
sql,
{"gid": group_id, "cid_req": client_request_id, "status": _STATUS_PENDING},
try:
with self._engine.begin() as conn:
existing = conn.execute(
text("SELECT 1 FROM risk_convert_detail WHERE convert_group_id = :gid"),
{"gid": group_id},
).first()
if existing is not None:
conn.execute(
text(
"UPDATE risk_convert_detail"
" SET status = :s WHERE convert_group_id = :gid"
),
{"s": _STATUS_PENDING, "gid": group_id},
)
return True
conn.execute(
text(
"""
INSERT INTO risk_convert_detail
(convert_group_id, client_request_id, status, estimated)
VALUES (:gid, :cid_req, :status, 0)
"""
),
{"gid": group_id, "cid_req": client_request_id, "status": _STATUS_PENDING},
)
except IntegrityError:
# uk_idem 竞态:同键的另一笔刚刚占位成功 → 本笔不持有,让路
logger.info(
"幂等键已被并发请求占用(本次让路):cid=%s gid=%s",
client_request_id,
group_id,
)
return False
return True
# ---------- 阶段二:回写 completed + 详情 ----------
+23 -3
View File
@@ -427,8 +427,22 @@ def convert_fund(
)
if rebuilt is not None:
return rebuilt
# 阶段一未成 → 复用同一 group_id 重跑,杜绝第二组流水
group_id = hit_gid
# 阶段一未成:必须区分「确定没跑成」与「在飞/未知」,否则同键并发会双跑
# —— `convert:idem:` 锁只包住本段幂等判定(出块即释放),窗口内重入会
# 与在飞的那笔**同时进入阶段一**(T-13 真库确定性交错实测:
# 未映射的 IntegrityError 直穿 → 500;若日后 `in_lot_id` 的派生规则被
# 改成随机值,同一窗口会升级为**真·双扣**)。
# · `failed`:阶段一定性失败(无流水)→ 复用同一 group_id 重跑,
# 这正是架构 §8.3「LOT_CONFLICT → 调用方退避重试」的服务端契约;
# · `expired`:占位已被 SLA 巡检判死(`cleanup_pending_convert.py`,S2)
# → 同样可安全复用;
# · `pending`:在飞与崩溃**不可区分** → 按并发处理,返回 202 交客户端
# 稍后重试(架构 §9「同键并发 → 202」);崩溃残留由 SLA 巡检置 expired
# 后自动放行(急用可 `cleanup_pending_convert.py --hours 0` 立即判死)。
if str(existing["status"]) in ("failed", "expired"):
group_id = hit_gid
else:
return {"status": PROCESSING, "convert_group_id": hit_gid}
else:
group_id = new_id("CNV", now)
placeholder = True
@@ -496,11 +510,17 @@ def convert_fund(
# ⑤ 阶段零:占位(仅带幂等键时;在全部 4xx 之后,故 4xx 不留占位)
if placeholder:
try:
crepo.insert_placeholder(group_id, cid_req)
owns = crepo.insert_placeholder(group_id, cid_req)
except Exception as exc: # noqa: BLE001
# 占位失败 = 无法保证幂等 → 不放行(PRD §7.3)
logger.exception("convert 占位失败:%s", group_id)
raise IdempotencyUnavailable(f"幂等占位失败:{exc}") from exc
if not owns:
# 同一 client_request_id 的并发请求已占住 uk_idem(各自生成过不同 group_id)
# → 本笔让路,回 202(架构 §9「同键并发 → 202」),由客户端稍后重试并发起幂等命中。
# 不这样收敛的话,此处会直穿未映射的 IntegrityError(503)。(T-13 真库实测)
logger.info("同键并发占位让路:cid=%s gid=%s", cid_req, group_id)
return {"status": PROCESSING, "convert_group_id": None}
out_trade_id = new_id("TRD", now)
in_trade_id = new_id("TRD", now)