Files
group_xinghuo_jinrong/tests/test_convert_concurrency.py
T

848 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T-13 真 MySQL 并发测试 · **T+1 受理/确认分离模型**(架构 §9 · PRD 验收 18/31)。
**为什么必须是真 MySQL**:本文件验的是 InnoDB **行锁 + 条件 UPDATE rowcount** 这套
数据库级并发原语。sqlite 内存库(`conftest.sqlite_engine`)共用单连接,多线程跑不出
真争抢——在那里"绿"是**假绿**(与本项目「方言语义一律上真库」的既有铁律一致)。
**为什么不算 CI 常规门禁**(架构 §10 原话:该用例不进 CI,归入 T-13 一并跑):
重载用例要 50 线程 + 真库,耗时长。本文件用环境变量 **`CONVERT_STRESS=1`** 实现该语义:
未设置时重载用例逐条 skip(给出可操作的原因),常规 `pytest -q` 不受拖累。
T-13 实测:`CONVERT_STRESS=1 pytest tests/test_convert_concurrency.py -q -s`。
**T+1 语义下的并发面(v1.0 用例的映射,convert_fund 退役后整体重写)**
- **份额争抢从受理挪到确认**:受理段只做 R-3 在途占用校验(不扣份额);
真正的 FIFO 扣减争抢在确认段 `apply_convert`(批次哨兵 `remain_qty >= :q` +
1213 死锁整事务重试)。硬不变量「不超卖」钉在**确认后**的流水/余量上。
- **受理超授竞态**:并发受理下 R-3 的占用读数会滞后(TOCTOU)→ 可能出现
Σ占用 > 池的**超授**;T+1 的兜底是确认段逐笔 FIFO 严格扣减(份额不足 →
部分成交 R-10 / `rejected`),绝不超卖 —— 这是设计语义,不是缺陷。
- **紧池 80% 病根消失**:v1.0「需求=供给并发扣减只到 80% 成交」的根因是实时
扣减的批次碎片争抢;T+1 确认**串行**(FR-C23)后同场景 100% 成交
(验收 31 设计目标,S1 用例钉住)。
- **v1.0 占位竞态用例(3b/3c)退役**:T+1 受理单是 Core 6 态状态机(无 agent
占位),同键幂等由 `uk_idem` + 客户级锁承接(N1 + 单测
`test_accept_uk_idem_race_falls_back_to_idempotent`)。
**隔离策略**(与 `test_convert_integration.py` 同款,三条同时成立,缺一即污染种子):
1. id 前缀:`CNV-CONC-` / `TRD-CONC-`(monkeypatch `convert_service._new_id`);
2. 数据自建:客户 `CUST-CONCTEST` / 产品 `PROD-CONCTEST?` 全自建,**绝不碰种子**——
`risk_demo_env` 的 teardown 不还原 `core_share_lot`/`core_holding`,借种子客户跑转换
会跨用例污染 `test_integration_risk.py`;
3. teardown 全清:函数级 fixture 按客户 + 前缀删两库全部自建行(幂等)。
**连接池**:并发用例用**专用 Engine**(`pool_size=60`)。默认池 `5+10` 会把 50 个线程
压成 15 路串行,削弱争抢强度、让"不超卖"变成弱证据;专用池只在压测用,常规用例仍走
`get_engine` 生产口径。
"""
from __future__ import annotations
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, datetime, time as dtime, timedelta
from decimal import Decimal
from statistics import mean
from uuid import uuid4
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.exc import OperationalError
from conftest import ensure_risk_demo_ready
from app.config.settings import settings # noqa: E402
from app.gateway.convert_core_repository import ConvertCoreRepository # noqa: E402
from app.repository.convert_repository import ConvertRepository # noqa: E402
from app.repository.convert_request_repository import ConvertRequestRepository # noqa: E402
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.repository.risk_repository import RiskRepository # noqa: E402
from app.service.convert import convert_service as cs # noqa: E402
from app.service.convert.confirm_service import confirm_batch, confirm_one # noqa: E402
from app.service.convert.convert_service import ( # noqa: E402
accept_convert,
compensate_convert,
)
from app.service.convert.errors import LotConflict # noqa: E402
from app.service.convert.trading_calendar import ( # noqa: E402
parse_cutoff,
resolve_accept_date,
)
from app.service.risk.rules import RiskThresholds # noqa: E402
from app.utils.db import _resolve_credentials # noqa: E402
ensure_risk_demo_ready()
# ── 隔离种子常量(全部带 CONCTEST 前缀)──────────────────────────────
CUSTOMER = "CUST-CONCTEST"
PROD_OUT = "PROD-CONCTESTO" # 转出:债基,申购费率 0.0030
PROD_IN = "PROD-CONCTESTI" # 转入:股基,申购费率 0.0080
COMPANY = "华夏模拟基金"
TA = "TA-CN-001"
OUT_RATE = Decimal("0.0030")
IN_RATE = Decimal("0.0080")
OUT_NAV = Decimal("1.0300")
IN_NAV = Decimal("0.9500")
FEE_TIERS = [
(0, 7, "0.0150"),
(7, 30, "0.0100"),
(30, 180, "0.0050"),
(180, 365, "0.0025"),
(365, None, "0.0000"),
]
_LOT_DAYS = 100 # 全部批次同一费率档(0.0050)→ 断言只看份额守恒,不被费用档干扰
_GROUP_LIKE = "CNV-CONC-%"
#: 架构 §8.3 建议的退避间隔(瞬时失败 → 调用方重试 ≤3 次)
BACKOFF_MS = (100, 200, 400)
#: 可重试异常(架构 §8.3 外部退避兜底的对象):批次争抢 `LotConflict` +
#: 确认事务 1213 死锁重试耗尽后上抛的 `OperationalError`。串行批处理(FR-C23)
# 下二者不应出现;绕过批处理锁并发 confirm_one 时由调用方退避兜底。
_RETRYABLE = (LotConflict, OperationalError)
_RETRYABLE_NAMES = {t.__name__ for t in _RETRYABLE}
def _stress_enabled() -> bool:
return os.environ.get("CONVERT_STRESS") == "1"
requires_stress = pytest.mark.skipif(
not _stress_enabled(),
reason="重载并发用例不进 CI 常规门禁(架构 §10);设 CONVERT_STRESS=1 显式开启",
)
def _noop_engine(out_trade: dict, in_trade: dict) -> dict:
"""空引擎 hook:并发用例只验**份额争抢**,不掺引擎/出单噪声。"""
return {"triggered_rules": [], "alert_ids": [], "aml_hit": False}
def _new_id(prefix: str, now: datetime) -> str:
"""`_new_id` 替换:id 带 `-CONC-` 段(与 `CNV-CONC-%` 清理前缀一致)。"""
return f"{prefix}-CONC-{uuid4().hex[:10].upper()}"
# ── 专用 Engine(放大连接池)─────────────────────────────────────────
def _big_pool_engine(database: str, role: str, size: int = 60):
"""建一个连接池放大的 Engine(与 `get_engine` 同 URL/凭据,仅池参数不同)。"""
user, password = _resolve_credentials(database, role)
auth = f"{user}:{password}" if password else user
url = (
f"mysql+pymysql://{auth}@{settings.mysql_host}:{settings.mysql_port}"
f"/{database}?charset=utf8mb4"
)
return create_engine(url, pool_pre_ping=True, pool_size=size, max_overflow=size)
# ── 种子与清理 ──────────────────────────────────────────────────────
def _accept_date(core) -> date:
"""受理日 T(生产 `resolve_accept_date` 走真库日历实时判定,脚本不手算)。"""
return resolve_accept_date(
_NOW,
CoreReadOnlyRepository(engine=core).is_open,
parse_cutoff(settings.convert_cutoff_time),
)
def _seed(core, lots: list[str]) -> Decimal:
"""自建隔离种子;`lots` = 各批次份额。返回份额池总量。
两端净值都锚在**受理日 T**(确认段 `get_nav_on(pid, T)` 精确匹配)。
"""
today = date.today()
base = datetime.combine(today, dtime(10, 0))
accept_day = _accept_date(core)
total = sum(Decimal(q) for q in lots)
with core.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date)"
" VALUES (:c, 'T13并发测试', :d)"
),
{"c": CUSTOMER, "d": today},
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at)"
" VALUES (:c, 'C5', :t, :exp)"
),
{
"c": CUSTOMER,
"t": base - timedelta(days=30),
"exp": base + timedelta(days=300),
},
)
for pid, name, ptype, rate in [
(PROD_OUT, "T13转出基金", "bond", OUT_RATE),
(PROD_IN, "T13转入基金", "stock", IN_RATE),
]:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code,"
" product_type, can_subscribe, can_redeem, subscribe_fee_rate,"
" min_redeem_qty, min_hold_qty, min_hold_action, fund_company, ta_code)"
" VALUES (:p, :n, 'R2', :t, 1, 1, :r, 0, 0, 'force_transfer', :co, :ta)"
),
{"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA},
)
for mh, mh_max, rate in FEE_TIERS:
conn.execute(
text(
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days,"
" max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mm, :r)"
),
{"p": PROD_OUT, "mh": mh, "mm": mh_max, "r": rate},
)
for pid, nav in ((PROD_OUT, OUT_NAV), (PROD_IN, IN_NAV)):
conn.execute(
text(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date)"
" VALUES (:p, :n, 0, :d)"
),
{"p": pid, "n": str(nav), "d": accept_day},
)
for i, qty in enumerate(lots, start=1):
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty,"
" remain_qty, nav, confirmed_at)"
" VALUES (:l, :c, :p, :q, :q, :n, :cat)"
),
{
"l": f"LOT-CONCTEST-{i}",
"c": CUSTOMER,
"p": PROD_OUT,
"q": qty,
"n": str(OUT_NAV),
"cat": base - timedelta(days=_LOT_DAYS),
},
)
conn.execute(
text(
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount,"
" market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :mv, 0, :d)"
),
{
"c": CUSTOMER,
"p": PROD_OUT,
"q": str(total),
"cost": str(total * OUT_NAV),
"mv": str(total * OUT_NAV),
"d": today,
},
)
return total
_CUST_LIKE = "CUST-CONCTEST%"
_PROD_LIKE = "PROD-CONCTEST%"
#: 删除顺序按真库 FK 依赖(`information_schema.KEY_COLUMN_USAGE` 实测):
#: `core_holding`/`core_share_lot`/`core_trade` 同时引用 `core_customer` 与
#: `core_product`,故必须先删它们,再删 `core_product`,最后删 `core_customer`。
_CORE_CLEANUP_BY_CUSTOMER = [
"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-CONC-%'",
"DELETE FROM core_trade WHERE customer_id LIKE :cl",
"DELETE FROM core_share_lot WHERE customer_id LIKE :cl",
"DELETE FROM core_holding WHERE customer_id LIKE :cl",
"DELETE FROM core_cash_flow WHERE customer_id LIKE :cl",
"DELETE FROM core_customer_advisor WHERE customer_id LIKE :cl",
"DELETE FROM core_customer_risk WHERE customer_id LIKE :cl",
# T-12:受理单引用 customer/product(fk_creq_*),必须在删 product 之前清
"DELETE FROM core_convert_request WHERE customer_id LIKE :cl",
]
_CORE_CLEANUP_BY_PRODUCT = [
f"DELETE FROM core_fee_rule WHERE product_id LIKE '{_PROD_LIKE}'",
f"DELETE FROM core_product_nav WHERE product_id LIKE '{_PROD_LIKE}'",
f"DELETE FROM core_convert_request WHERE from_product_id LIKE '{_PROD_LIKE}'"
f" OR to_product_id LIKE '{_PROD_LIKE}'",
f"DELETE FROM core_product WHERE product_id LIKE '{_PROD_LIKE}'",
]
def _cleanup(core, agent) -> None:
"""按客户 + 前缀清两库全部自建行(幂等;seed 失败亦可清)。"""
params = {"cl": _CUST_LIKE}
with core.begin() as conn:
conn.execute(
text(
"DELETE FROM core_convert_lot_detail"
f" WHERE convert_group_id LIKE '{_GROUP_LIKE}'"
)
)
for sql in _CORE_CLEANUP_BY_CUSTOMER:
conn.execute(text(sql), params)
for sql in _CORE_CLEANUP_BY_PRODUCT:
conn.execute(text(sql))
conn.execute(text("DELETE FROM core_customer WHERE customer_id LIKE :cl"), params)
with agent.begin() as conn:
conn.execute(
text(f"DELETE FROM risk_convert_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'")
)
for table in ("risk_alert", "risk_suitability_log", "audit_log"):
conn.execute(text(f"DELETE FROM {table} WHERE customer_id LIKE :cl"), params)
@pytest.fixture()
def conc_env(risk_demo_env, monkeypatch):
"""真库并发环境:admin 引擎(teardown 需 DELETE)+ 专用大池引擎 + id 前缀注入。
种子由用例自己 `_seed`(不同用例份额池不同),fixture 只负责清场与收尾。
"""
core_admin = risk_demo_env["core"]
agent_admin = risk_demo_env["agent"]
core_ro = _big_pool_engine(settings.mysql_core_database, "ro")
core_rw = _big_pool_engine(settings.mysql_core_database, "rw")
agent_rw = _big_pool_engine(settings.mysql_database, "rw")
monkeypatch.setattr(cs, "_new_id", _new_id)
_cleanup(core_admin, agent_admin)
try:
yield {
"core": core_admin,
"agent": agent_admin,
"core_ro": core_ro,
"core_rw": core_rw,
"agent_rw": agent_rw,
}
finally:
_cleanup(core_admin, agent_admin)
for eng in (core_ro, core_rw, agent_rw):
eng.dispose()
# ── T+1 两段调用(受理 / 确认;生产账号 + 大池引擎)────────────────────
_NOW = datetime.combine(date.today(), dtime(10, 0))
def _accept(env, qty: str, cid: str | None = None, customer: str = CUSTOMER) -> dict:
"""T 日受理(不扣份额;生产口径,仅 id 工厂被替换)。"""
return accept_convert(
{
"customer_id": customer,
"from_product_id": PROD_OUT,
"to_product_id": PROD_IN,
"qty": qty,
"client_request_id": cid,
},
core_ro=CoreReadOnlyRepository(engine=env["core_ro"]),
risk_repo=RiskRepository(engine=env["agent_rw"]),
convert_repo=ConvertRepository(engine=env["agent_rw"]),
request_repo=ConvertRequestRepository(engine=env["core_rw"]),
now=_NOW,
)
def _confirm(env, gid: str, hook=_noop_engine) -> dict:
"""T+1 确认一笔(as_of=受理日,补跑语义;确认事务含 FIFO 扣减哨兵)。
`id_factory` 显式注入:`confirm_one` 的 trade_id 走 `format.new_id`
(与 `convert_service._new_id` 是两个符号),monkeypatch 罩不到 → 必须传参。
"""
return confirm_one(
gid,
core_ro=CoreReadOnlyRepository(engine=env["core_ro"]),
risk_repo=RiskRepository(engine=env["agent_rw"]),
convert_repo=ConvertRepository(engine=env["agent_rw"]),
request_repo=ConvertRequestRepository(engine=env["core_rw"]),
core_writer=ConvertCoreRepository(engine=env["core_rw"]),
thresholds=RiskThresholds.from_settings(),
now=_NOW,
as_of=_accept_date(env["core"]),
engine_hook=hook,
id_factory=_new_id,
)
class Outcome:
"""一个并发请求的终态(是否成功、重试了几次、最终异常)。"""
__slots__ = ("ok", "retries", "error", "group_id")
def __init__(self, ok, retries, error=None, group_id=None):
self.ok = ok
self.retries = retries
self.error = error
self.group_id = group_id
def _confirm_with_backoff(env, gid: str) -> Outcome:
"""按架构 §8.3 的建议间隔退避重试确认(≤3 次;1213 类瞬时失败)。"""
attempt = 0
while True:
try:
resp = _confirm(env, gid)
except _RETRYABLE as exc:
if attempt >= len(BACKOFF_MS):
return Outcome(False, attempt, type(exc).__name__)
time.sleep(BACKOFF_MS[attempt] / 1000.0)
attempt += 1
continue
except Exception as exc: # noqa: BLE001
return Outcome(False, attempt, type(exc).__name__)
return Outcome(
resp["status"] == "confirmed",
attempt,
None if resp["status"] == "confirmed" else resp["status"],
group_id=gid,
)
def _concurrently(fn, n: int, *, stagger_ms: float = 0.0) -> list:
"""n 个线程同时起跑;`stagger_ms` 为逐线程错开步长(0 = 全同时)。
起跑线用 `threading.Barrier` 对齐,避免"线程创建开销"把并发抹平。
"""
barrier = threading.Barrier(n)
def _worker(i: int):
barrier.wait()
if stagger_ms:
time.sleep(stagger_ms * i / 1000.0)
return fn(i)
with ThreadPoolExecutor(max_workers=n) as pool:
return list(pool.map(_worker, range(n)))
# ── 度量(全部直接读库,不看响应自证)────────────────────────────────
def _remain_total(core) -> Decimal:
with core.connect() as conn:
return Decimal(
str(
conn.execute(
text(
"SELECT COALESCE(SUM(remain_qty), 0) FROM core_share_lot"
" WHERE customer_id = :c AND product_id = :p"
),
{"c": CUSTOMER, "p": PROD_OUT},
).scalar_one()
)
)
def _deducted_total(core) -> Decimal:
"""实际成交的转出份额 = 全部 `redeem` 流水的 qty 之和(转出端=客户被扣的部分)。"""
with core.connect() as conn:
return Decimal(
str(
conn.execute(
text(
"SELECT COALESCE(SUM(qty), 0) FROM core_trade"
" WHERE customer_id = :c AND trade_type = 'redeem'"
),
{"c": CUSTOMER},
).scalar_one()
)
)
def _trades(core) -> list[dict]:
with core.connect() as conn:
return [
dict(r)
for r in conn.execute(
text("SELECT * FROM core_trade WHERE customer_id = :c ORDER BY trade_id"),
{"c": CUSTOMER},
).mappings()
]
def _group_counts(core) -> dict[str, int]:
"""convert_group_id → 该组流水条数(正常恒为 2)。"""
counts: dict[str, int] = {}
for row in _trades(core):
gid = row["convert_group_id"]
counts[gid] = counts.get(gid, 0) + 1
return counts
def _request_statuses(core) -> dict[str, int]:
"""受理单 6 态分布(T+1 权威状态机)。"""
with core.connect() as conn:
rows = conn.execute(
text(
"SELECT status, COUNT(*) AS n FROM core_convert_request"
" WHERE customer_id = :c GROUP BY status"
),
{"c": CUSTOMER},
).mappings()
return {r["status"]: int(r["n"]) for r in rows}
def _assert_no_oversell(core, pool: Decimal) -> None:
"""硬不变量:不超卖 / 守恒 / 每组恰好两条流水(无重复组)。"""
deducted = _deducted_total(core)
remain = _remain_total(core)
assert deducted <= pool, f"超卖:扣减 {deducted} > 池 {pool}"
assert remain == pool - deducted, f"不守恒:余 {remain} ≠ 池 {pool} − 扣 {deducted}"
counts = _group_counts(core)
assert set(counts.values()) <= {2}, f"存在流水条数 ≠2 的组:{counts}"
# ── N1. 同键并发受理:只允许落一张受理单(uk_idem + 客户级锁)──────────
def test_same_client_request_id_concurrent_produces_single_acceptance(conc_env):
"""同 `client_request_id` 并发**受理** → core_convert_request 恰 1 行。
T+1 语义(取代 v1.0「同键并发只扣一次」):受理不扣份额,幂等收敛目标是
**受理单唯一** —— 客户级锁串行化 + `uk_idem` 兜底让路,终态允许
「幂等命中(同 gid)」或「让路 202」,绝不允许第二张受理单、也绝无 5xx。
(uk_idem 竞态兜底的单测锚点:`test_accept_uk_idem_race_falls_back_to_idempotent`)
"""
core = conc_env["core"]
_seed(core, ["50000"])
n = 8
cid = f"CONC-SAME-{uuid4().hex[:8]}"
def _one(_i: int) -> dict:
try:
return _accept(conc_env, "120", cid)
except Exception as exc: # noqa: BLE001
return {"_error": type(exc).__name__}
outcomes = _concurrently(_one, n, stagger_ms=1.5)
errs = [o["_error"] for o in outcomes if "_error" in o]
gids = {o["convert_group_id"] for o in outcomes if "_error" not in o}
print(
f"\n[同键并发受理 {n} 路] 受理单 {len(_request_statuses(core))} 张 · "
f"gid 去重 {len(gids)} · 错误 {errs}"
)
# 唯一硬约束:恰一张受理单(uk_idem),且无 5xx
statuses = _request_statuses(core)
assert sum(statuses.values()) == 1, f"同键并发落了 {sum(statuses.values())} 张受理单"
assert len(gids) <= 1, f"同键应幂等命中同一 gid:{gids}"
assert not errs, f"同键并发出现了异常:{errs}"
# ── N2. 并发补偿只落一次(T-12 的并发面 · T+1 造数)────────────────────
def test_concurrent_compensation_writes_detail_once(conc_env, monkeypatch):
"""待补偿现场并发补偿(`convert:rerun:` 锁)→ 详情只补一次、只有一组流水。
T+1 造数:真受理 → 真确认(Core 三件套 + 镜像 completed + 引擎 hook 静默)
→ 镜像 UPDATE 回 `failed` 制造「确认段第 ⑧ 步失败」现场(受理单已 confirmed,
补偿前置满足)。
"""
core = conc_env["core"]
agent = conc_env["agent"]
_seed(core, ["50000"])
resp = _accept(conc_env, "120", f"CONC-CMP-{uuid4().hex[:8]}")
gid = resp["convert_group_id"]
out = _confirm(conc_env, gid)
assert out["status"] == "confirmed"
# 制造待补偿现场:镜像回退 failed(Core 权威不动)
with agent.begin() as conn:
conn.execute(
text("UPDATE risk_convert_detail SET status = 'failed' WHERE convert_group_id = :g"),
{"g": gid},
)
def _comp(_i: int) -> str:
svc = dict(
core_ro=CoreReadOnlyRepository(engine=conc_env["core_ro"]),
risk_repo=RiskRepository(engine=conc_env["agent_rw"]),
convert_repo=ConvertRepository(engine=conc_env["agent_rw"]),
)
result = compensate_convert(gid, thresholds=RiskThresholds.from_settings(), now=_NOW, **svc)
return result["state"]
states = _concurrently(_comp, 6, stagger_ms=1.0)
print(f"\n[并发补偿 6 路] 状态分布 {states}")
assert states.count("rebuilt") == 1, f"应恰好 1 路真补写,实际 {states}"
with agent.connect() as conn:
rows = [
dict(r)
for r in conn.execute(
text("SELECT status FROM risk_convert_detail WHERE convert_group_id = :g"),
{"g": gid},
).mappings()
]
assert rows == [{"status": "completed"}], f"详情应只有一行 completed:{rows}"
assert len(_group_counts(core)) == 1 and len(_trades(core)) == 2
# 补偿不动 Core 权威:受理单保持 confirmed、流水不增
with core.connect() as conn:
status = conn.execute(
text("SELECT status FROM core_convert_request WHERE convert_group_id = :g"),
{"g": gid},
).scalar_one()
assert status == "confirmed"
# ── S1. 紧池(需求=供给):受理 100% + 串行确认 100%(验收 31 载体)────
@requires_stress
def test_tight_pool_accept_all_then_confirm_100pct(conc_env):
"""50 并发受理各 1000、池恰好 50000(需求=供给)→ **受理 100% + 确认 100%**。
这是 PRD 验收 31 的 T+1 载体:v1.0 同场景实时扣减只到 **80%**(40/50,
批次碎片争抢,见交接文档 §B.6.6)——根因是「确认前就扣份额」。T+1 受理
不扣份额(R-3 占用校验:50×1000 恰好 ≤ 池,**全部 accepted 是确定性结果**),
确认**串行**(FR-C23)无争抢 → 逐笔 FIFO 全额成交 → 100%。
硬门禁:① 受理恰 50 张 accepted;② 确认恰 50 张 confirmed(100%);
③ Σ扣减 = 池 = 50000(需求=供给全额消化);④ 守恒 + 无超卖。
"""
core = conc_env["core"]
pool = _seed(core, ["10000"] * 5)
n = 50
outcomes = _concurrently(lambda i: _accept(conc_env, "1000"), n, stagger_ms=0.5)
accepted = [o for o in outcomes if o.get("accepted")]
print(
f"\n[紧池·受理] accepted {len(accepted)}/{n} · 其余 "
f"{[o.get('block_reason') or o.get('_error') for o in outcomes if not o.get('accepted')]}"
)
assert len(accepted) == n, f"受理应 100%(设计目标),实际 {len(accepted)}"
statuses = _request_statuses(core)
assert statuses.get("accepted") == n, f"受理单应全为 accepted:{statuses}"
# T+1 确认:批处理串行(as_of = 受理日,补跑语义)
accept_day = _accept_date(core)
batch = confirm_batch(
as_of=accept_day,
core_ro=CoreReadOnlyRepository(engine=conc_env["core_ro"]),
risk_repo=RiskRepository(engine=conc_env["agent_rw"]),
convert_repo=ConvertRepository(engine=conc_env["agent_rw"]),
request_repo=ConvertRequestRepository(engine=conc_env["core_rw"]),
core_writer=ConvertCoreRepository(engine=conc_env["core_rw"]),
thresholds=RiskThresholds.from_settings(),
now=_NOW,
engine_hook=_noop_engine,
id_factory=_new_id,
)
print(
f"\n[紧池·串行确认] scanned={batch['scanned']} confirmed={batch['confirmed']} "
f"rejected={batch.get('rejected', 0)} nav_pending={batch.get('nav_pending', 0)}"
)
assert batch["scanned"] == n, f"批处理应捞到全部 {n} 张"
assert batch["confirmed"] == n, f"串行确认应 100%(验收 31 目标),实际 {batch}"
statuses = _request_statuses(core)
assert statuses.get("confirmed") == n, f"受理单应全为 confirmed:{statuses}"
# 需求=供给 → 池被全额消化且守恒(无超卖硬门禁)
assert _deducted_total(core) == pool, f"扣减 {_deducted_total(core)} 应恰等于池 {pool}"
_assert_no_oversell(core, pool)
assert len({g for g in _group_counts(core)}) == n, "50 张单应有 50 个独立组"
# ── S2. 并发确认争抢同一池:不许超卖(评审 Q6 断言的 T+1 承接)──────────
@requires_stress
def test_concurrent_confirms_never_oversell(conc_env):
"""并发受理允许**超授**(R-3 占用读数滞后的 TOCTOU),并发确认兜底 → 不许超卖。
50 线程 × 各受理 2000(池 50000):受理段占用校验读数滞后时可能出现
Σ占用 > 池的超授 —— T+1 的资金安全不依赖受理段,而在**确认段扣减哨兵**
(`remain_qty >= :q` 条件 UPDATE + 死锁重试):份额不足时逐笔部分成交
(R-10)或 `rejected`,Σ实际扣减绝不越过池。
典型失效形态:若确认事务丢掉扣减哨兵,50 笔会**全部**全额成交 →
扣减 100000 > 池 50000 → 本用例红。
"""
core = conc_env["core"]
pool = _seed(core, ["10000"] * 5)
n = 50
# 受理段:并发窗口内 R-3 占用读数滞后 → 允许超授(Σ占用 > 池);
# 校验命中时直接 `InsufficientShares`(受理段 4xx,异常而非 blocked)。
def _try_accept(_i: int) -> dict:
try:
return _accept(conc_env, "2000")
except Exception as exc: # noqa: BLE001 - 受理失败本身是合法观测面
return {"_error": type(exc).__name__}
accepted = _concurrently(_try_accept, n, stagger_ms=0.5)
gids = [o["convert_group_id"] for o in accepted if o.get("accepted")]
accept_errors = {o["_error"] for o in accepted if "_error" in o}
print(
f"\n[并发确认·不超卖] 受理 {len(gids)}/{n}(允许超授)· 受理失败类型 {accept_errors} · "
f"受理单分布 {_request_statuses(core)}"
)
assert gids, "至少应有部分受理成功"
# 确认段:并发 confirm_one 争抢扣减 → 兜底不允许超卖
confirmed = _concurrently(lambda i: _confirm_with_backoff(conc_env, gids[i]), len(gids))
ok = [o for o in confirmed if o.ok]
# 合法失败面(都是**零扣减**的让路终态,不碰资金安全):
# - `rejected`:份额被抢空 → 复核拒绝(占用释放);
# - `LotConflict`:退避耗尽仍撞批次 → 让路。并发 confirm_one 本身是**超纲场景**
# (生产由批处理锁保证串行,FR-C23),让路单受理单保持 accepted,
# 占用保持、下轮批处理再确认 —— 与 v1.0 用例 1 允许 LotConflict 同口径。
legit_fail = {"rejected", "LotConflict"}
unexpected = {o.error for o in confirmed if not o.ok and o.error not in legit_fail}
print(
f"\n[并发确认·不超卖] confirmed {len(ok)}/{len(gids)} · 让路/拒绝 "
f"{sorted({o.error for o in confirmed if not o.ok})} · "
f"未映射异常 {unexpected} · 扣减 {_deducted_total(core)} / 池 {pool}"
)
# ① 硬不变量:不超卖 / 守恒 / 每组恰好两条流水
_assert_no_oversell(core, pool)
# ② 失败面:终态只能是 confirmed/rejected/LotConflict(部分成交含在 confirmed 内),
# 不得有未映射异常直穿
assert not unexpected, f"确认出现未映射异常:{unexpected}"
# ③ 让路不吞单:没确认成的单,受理单必须保持 accepted(占用保持,下轮可确认)
statuses = _request_statuses(core)
assert statuses.get("accepted", 0) == len(gids) - len(ok), (
f"让路单受理单状态异常:{statuses}(成功 {len(ok)}/{len(gids)})"
)
# ④ 反"假绿"地板:confirmed 塌穿 20 说明扣减路径退化(哨兵过严/锁失效)
assert len(ok) >= 20, f"确认仅 {len(ok)} 笔,扣减路径疑似退化"
# ── S3. 多客户并发确认:互不阻塞,但会出 1213 死锁(双层重试收敛)───────
@requires_stress
def test_concurrent_different_customers_need_deadlock_retry(conc_env):
"""8 个客户**并发确认**同一转出产品 → 全部成功,但**必须靠重试死锁**。
**实测发现(2026-09-10,v1.0 阶段一)**:跨客户并发在 `core_holding`/
`core_share_lot` 的唯一索引上会触发 InnoDB **1213 死锁**(RR 隔离级别下
INSERT/UPDATE 的插入意向锁与间隙锁互斥)。T+1 下同一争抢面在**确认事务**
(`apply_convert`,1213 自动重试整事务,settings 可配);本用例保留 §8.3
的**外部退避兜底**,双层重试叠加下 8 客户并发确认仍须全部成功、份额守恒、
group_id 两两不同。受理段先串行完成(不扣份额、无争抢面)。
"""
core = conc_env["core"]
_seed(core, ["5000"] * 2)
others = [f"CUST-CONCTEST-{i}" for i in range(1, 8)]
with core.begin() as conn:
for c in others:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, open_date)"
" VALUES (:c, 'T13并发客户', CURDATE())"
),
{"c": c},
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at,"
" expires_at) VALUES (:c, 'C5', NOW(), DATE_ADD(NOW(), INTERVAL 300 DAY))"
),
{"c": c},
)
conn.execute(
text(
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty,"
" remain_qty, nav, confirmed_at)"
" VALUES (:l, :c, :p, 5000, 5000, :n, DATE_SUB(NOW(), INTERVAL 100 DAY))"
),
{"l": f"LOT-CONCTEST-OTH-{c}", "c": c, "p": PROD_OUT, "n": str(OUT_NAV)},
)
customers = [CUSTOMER, *others]
# 受理先串行完成(不扣份额;其他客户的受理走同款服务路径)
gids = {}
for c in customers:
resp = _accept(conc_env, "1000", customer=c)
assert resp.get("accepted"), f"{c} 受理失败:{resp}"
gids[c] = resp["convert_group_id"]
def _one(i: int) -> Outcome:
return _confirm_with_backoff(conc_env, gids[customers[i]])
outcomes = _concurrently(_one, len(customers))
ok = [o for o in outcomes if o.ok]
deadlock_retries = sum(o.retries for o in outcomes)
print(
f"\n[8 客户并发确认] 成功 {len(ok)}/8 · 死锁重试累计 {deadlock_retries} 次 · "
f"失败 {[o.error for o in outcomes if not o.ok]}"
)
assert len(ok) == 8, f"退避重试后应全部成功:{[o.error for o in outcomes if not o.ok]}"
with core.connect() as conn:
total = Decimal(
str(
conn.execute(
text(
"SELECT COALESCE(SUM(remain_qty), 0) FROM core_share_lot"
" WHERE product_id = :p AND lot_id LIKE 'LOT-CONCTEST%'"
),
{"p": PROD_OUT},
).scalar_one()
)
)
# 8 个客户各转出 1000;主客户有 2 批 × 5000 = 10000,其余 7 个各 5000
# → 初始合计 45000,扣 8000 → 37000
expected = Decimal("45000") - Decimal("1000") * len(customers)
assert total == expected, f"剩余合计应为 {expected},实际 {total}"
assert len({o.group_id for o in ok}) == 8, "8 个客户的 group_id 必须两两不同"
# ── S4. 性能实测(PRD §9 第 18 条 · T+1 两段)──────────────────────────
class _TimingWriter:
"""`ConvertCoreRepository` 代理:只测**确认事务**耗时,不改任何行为。"""
def __init__(self, inner):
self._inner = inner
self.samples: list[float] = []
def apply_convert(self, req):
t0 = time.perf_counter()
try:
return self._inner.apply_convert(req)
finally:
self.samples.append((time.perf_counter() - t0) * 1000.0)
def _pct(values: list[float], p: float) -> float:
ordered = sorted(values)
idx = min(len(ordered) - 1, max(0, int(round((p / 100.0) * len(ordered) + 0.5)) - 1))
return ordered[idx]
@requires_stress
def test_performance_probe_accept_confirm_end_to_end(conc_env):
"""实测:**受理事务 + 确认事务 + 端到端两段**耗时(PRD §9 第 18 条补录来源)。
测量条件:本机 MySQL 8.0.46(127.0.0.1)、模拟库、**单线程顺序**、
份额池充足的稳态(每笔 1 份,避免 FIFO 跨批波动);端到端含空引擎 hook
(真实引擎的额外开销在 `test_convert_integration` 另行体现)。
端到端 = 受理(校验+落单+镜像+审计)+ 确认(折算+扣减+流水+镜像+审计)两段之和,
PRD 验收 18 的硬指标是 **< 2s**。
"""
core = conc_env["core"]
n = 60
_seed(core, [str(n)] * 1)
writer = _TimingWriter(ConvertCoreRepository(engine=conc_env["core_rw"]))
accept_ms: list[float] = []
confirm_ms: list[float] = []
for i in range(n):
t0 = time.perf_counter()
resp = _accept(conc_env, "1", f"CONC-PERF-{i}")
accept_ms.append((time.perf_counter() - t0) * 1000.0)
gid = resp["convert_group_id"]
t1 = time.perf_counter()
out = confirm_one(
gid,
core_ro=CoreReadOnlyRepository(engine=conc_env["core_ro"]),
risk_repo=RiskRepository(engine=conc_env["agent_rw"]),
convert_repo=ConvertRepository(engine=conc_env["agent_rw"]),
request_repo=ConvertRequestRepository(engine=conc_env["core_rw"]),
core_writer=writer,
thresholds=RiskThresholds.from_settings(),
now=_NOW,
as_of=_accept_date(core),
engine_hook=_noop_engine,
id_factory=_new_id,
)
confirm_ms.append((time.perf_counter() - t1) * 1000.0)
assert out["status"] == "confirmed", out
# 首个样本含连接池冷启动,剔除(否则把"建连"算进"业务耗时")
stage_confirm = writer.samples[1:]
acc = accept_ms[1:]
cfm = confirm_ms[1:]
e2e = [a + c for a, c in zip(acc, cfm)]
print(
f"\n[性能实测 n={len(e2e)}] 受理 ms: P50={_pct(acc, 50):.1f} P95={_pct(acc, 95):.1f} "
f"max={max(acc):.1f} mean={mean(acc):.1f}"
f"\n 确认事务 ms: P50={_pct(stage_confirm, 50):.1f} "
f"P95={_pct(stage_confirm, 95):.1f} max={max(stage_confirm):.1f} mean={mean(stage_confirm):.1f}"
f"\n 确认全程 ms: P50={_pct(cfm, 50):.1f} P95={_pct(cfm, 95):.1f} max={max(cfm):.1f}"
f"\n 端到端(受理+确认) ms: P50={_pct(e2e, 50):.1f} P95={_pct(e2e, 95):.1f} "
f"max={max(e2e):.1f} mean={mean(e2e):.1f}"
)
# PRD §9 第 18 条的验收硬指标是端到端 < 2s
assert max(e2e) < 2000, f"端到端最大 {max(e2e):.1f}ms 超 PRD §9 第 18 条的 2s"