fix(notifications): deterministic tiebreaker for paginated notification log list
Same-class gap as e369508 (alerts list): single-key ORDER BY created_at DESC plus offset/limit lets MySQL filesort return an unstable order inside same-second tie groups, so offset pagination duplicates and skips rows. Append secondary key id DESC, aligning with app/api/alerts.py, app/api/cash.py and app/api/data_quality.py.
Entity-scope caliber untouched: parent-key join attribution, alert_id IS NULL rows still excluded, policy/note wording unchanged. Local commit only - no push (release gated by ASK-20260925-04).
This commit is contained in:
@@ -124,8 +124,13 @@ def list_logs(page: int = 1,
|
||||
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
|
||||
.filter(KPIAlert.entity_id == entity_id))
|
||||
total = scoped.count()
|
||||
# 排序补**确定性次键** `id DESC`(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack):
|
||||
# 通知日志由预警推送批量写入,同一秒多行时 MySQL filesort 在并列组内顺序不受控 ⇒ 单键
|
||||
# `created_at DESC` + offset/limit 会让偏移分页重复并漏行。两键后偏移分页成为全序分页。
|
||||
# 同族先例:app/api/alerts.py(e369508)、app/api/cash.py:165、app/api/data_quality.py:230。
|
||||
# 本行**不改**任何账套收口口径(join 归属 / `alert_id IS NULL` 不返回 / policy+note 文案)。
|
||||
logs = scoped.order_by(
|
||||
NotificationLog.created_at.desc()
|
||||
NotificationLog.created_at.desc(), NotificationLog.id.desc()
|
||||
).offset((page - 1) * 20).limit(20).all()
|
||||
return {
|
||||
"total": total,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`GET /api/cma/notifications/logs` 通知历史分页确定性回归(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack)
|
||||
|
||||
缺陷(与 `app/api/alerts.py::list_alerts` 同构,2026-09-25 项目Bot 实读现件确认):
|
||||
`app/api/notifications.py::list_logs` 用 `ORDER BY created_at DESC` **单键** + `offset/limit`
|
||||
分页;`notification_logs` 由预警推送**批量写入**,同一秒多行 ⇒ MySQL filesort 在并列组内顺序
|
||||
不受控,偏移分页会**重复并漏行**(同秒内后写入者可能被前页吞掉、或同一行跨页出现两次)。
|
||||
|
||||
修复:排序补**确定性次键** `id DESC` ⇒ `created_at DESC, id DESC`,偏移分页成为全序分页,
|
||||
逐页遍历覆盖全部 total 行且零重复。先例(同族两键):`app/api/alerts.py`(e369508)、
|
||||
`app/api/cash.py:165`、`app/api/data_quality.py:230`。
|
||||
|
||||
本套件判据(改前 RED / 改后 GREEN):
|
||||
· T1 唯一性:逐页取满后 `唯一 id 数 == total` 且 `重复 == 0`;
|
||||
· T2 确定性次键:并列 `created_at` 组内 id **严格降序**(改前同一夹具下为插入序 = 升序 → RED);
|
||||
· T3 逐页序列 == 「一次性全量取」(无分页)按 (created_at desc, id desc) 的序列,逐项相同;
|
||||
· T4 负对照(非回归):账套收口口径未动 —— 他账套(经父键归属)行不可见、
|
||||
`alert_id IS NULL` 行仍不返回、`policy`/`note` 文案不变;
|
||||
· T5 负对照(非回归):跨时间戳仍「新→旧」。
|
||||
|
||||
夹具口径照 `tests/test_alerts_pagination_stability_20260925.py`(同秒 60+ 行 + 跨时间戳若干行)。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import KPIAlert, NotificationLog
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
|
||||
PAGE_SIZE = 20 # app/api/notifications.py 硬编码 limit(20)
|
||||
|
||||
#: 固定「同秒」时间戳 —— 全部并列行共用,复刻生产「预警推送批量写入、同秒 created_at」形态
|
||||
SAME_SECOND = datetime(2026, 9, 20, 7, 0, 42)
|
||||
|
||||
POLICY_TEXT = "alert-linked-only"
|
||||
|
||||
|
||||
def _alert(db: Session, kpi_id: int, entity_id: int = 1) -> KPIAlert:
|
||||
a = KPIAlert(
|
||||
kpi_id=kpi_id,
|
||||
entity_id=entity_id,
|
||||
alert_level="yellow",
|
||||
alert_message="通知分页夹具",
|
||||
status="pending",
|
||||
created_at=SAME_SECOND,
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return a
|
||||
|
||||
|
||||
def _log(db: Session, alert_id, **kw) -> NotificationLog:
|
||||
"""一条通知日志;`alert_id=None` = 无可归属父键(口径:不返回)"""
|
||||
defaults = {
|
||||
"alert_id": alert_id,
|
||||
"channel": "wecom",
|
||||
"recipient": "renzong",
|
||||
"title": "预警推送",
|
||||
"content": "分页夹具",
|
||||
"status": "sent",
|
||||
"created_at": SAME_SECOND,
|
||||
}
|
||||
defaults.update(kw)
|
||||
row = NotificationLog(**defaults)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _walk(client: TestClient, token: str) -> dict:
|
||||
"""逐页调用真实端点,直到某页不足 PAGE_SIZE 行(= 前端/调用方遍历姿势)。"""
|
||||
url = "/api/cma/notifications/logs"
|
||||
pages, seen, dups, rows = [], [], [], []
|
||||
page = 1
|
||||
total = None
|
||||
policy = note = None
|
||||
while True:
|
||||
resp = client.get(f"{url}?page={page}", headers=auth_header(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
if total is None:
|
||||
total = body["total"]
|
||||
policy = body.get("policy")
|
||||
note = body.get("note")
|
||||
else:
|
||||
assert body["total"] == total, "翻页过程中 total 漂移(口径/过滤不一致)"
|
||||
ids = [r["id"] for r in body["data"]]
|
||||
for pos, r in enumerate(body["data"]):
|
||||
if r["id"] in seen:
|
||||
dups.append({"id": r["id"], "page": page, "pos": pos})
|
||||
else:
|
||||
seen.append(r["id"])
|
||||
rows.append(r)
|
||||
pages.append(ids)
|
||||
if len(ids) < PAGE_SIZE:
|
||||
break
|
||||
page += 1
|
||||
assert page < 100, "分页遍历未终止(保险丝)"
|
||||
return {"total": total, "pages": pages, "unique": seen, "dups": dups,
|
||||
"rows": rows, "policy": policy, "note": note}
|
||||
|
||||
|
||||
def _tie_groups(rows):
|
||||
groups = {}
|
||||
for r in rows:
|
||||
groups.setdefault(str(r["created_at"]), []).append(r["id"])
|
||||
return groups
|
||||
|
||||
|
||||
class TestNotificationsLogsPaginationStability:
|
||||
"""分页可及性 + 确定性次键(判据主体;改前 T2 应为 RED)"""
|
||||
|
||||
def test_t1_t2_t3_tie_heavy_batch_all_rows_reachable_and_deterministic(
|
||||
self, client: TestClient, db: Session):
|
||||
"""61 条同秒 + 5 条跨时间戳:逐页取满 ⇒ 唯一==total、零重复、并列组 id 降序、与全量序列一致"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = _alert(db, kpi.id)
|
||||
|
||||
# 61 条同秒(>3 页,且每页必然出现「并列组跨界」)+ 5 条更早的(跨时间戳排序正对照)
|
||||
tie_ids = [_log(db, alert.id).id for _ in range(61)]
|
||||
older_ids = [_log(db, alert.id,
|
||||
created_at=SAME_SECOND - timedelta(hours=1)).id for _ in range(5)]
|
||||
total_expected = 66
|
||||
|
||||
w = _walk(client, token)
|
||||
assert w["total"] == total_expected, w["total"]
|
||||
|
||||
# T1 唯一性与重复
|
||||
assert len(set(w["unique"])) == w["total"], (
|
||||
f"逐页遍历唯一 id 数 {len(set(w['unique']))} != total {w['total']};重复 {w['dups']}")
|
||||
assert w["dups"] == [], f"偏移分页出现重复行:{w['dups']}"
|
||||
assert len(w["unique"]) == w["total"], "逐页取到的行数应等于 total"
|
||||
|
||||
# T2 确定性次键:并列组内 id 严格降序
|
||||
for ca, ids in _tie_groups(w["rows"]).items():
|
||||
if len(ids) > 1:
|
||||
assert ids == sorted(ids, reverse=True), (
|
||||
f"created_at={ca} 并列组内未按 id 降序(分页顺序不确定):{ids}")
|
||||
|
||||
# T3 逐页拼接序列 == 一次性全量取(不同查询路径:单次全量 ORM 查询,显式两键排序)
|
||||
full = (db.query(NotificationLog)
|
||||
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
|
||||
.filter(KPIAlert.entity_id == 1)
|
||||
.order_by(NotificationLog.created_at.desc(), NotificationLog.id.desc())
|
||||
.all())
|
||||
assert [r["id"] for r in w["rows"]] == [l.id for l in full], "逐页序列与全量排序序列不一致"
|
||||
# 全量序列 = 5 条跨时间戳(新→旧)+ 61 条同秒(id 降序)
|
||||
assert [r["id"] for r in w["rows"]][:5] == sorted(tie_ids, reverse=True)[:5]
|
||||
assert set(w["unique"]) == set(tie_ids) | set(older_ids)
|
||||
|
||||
def test_t4_entity_scope_and_null_parent_key_exclusion_unchanged(
|
||||
self, client: TestClient, db: Session):
|
||||
"""负对照:账套收口口径未动 —— 他账套行不可见、`alert_id IS NULL` 行不返回、policy/note 不变"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
|
||||
mine_alert = _alert(db, kpi.id, entity_id=1)
|
||||
other_alert = _alert(db, kpi.id, entity_id=2)
|
||||
|
||||
mine = [_log(db, mine_alert.id).id for _ in range(25)]
|
||||
other = [_log(db, other_alert.id).id for _ in range(25)]
|
||||
orphan = [_log(db, None).id for _ in range(5)] # 无父键(如 OKR 复盘提醒形态)
|
||||
|
||||
w = _walk(client, token)
|
||||
assert w["total"] == len(mine), "账套收口被改:total 不再等于本账套可归属行数"
|
||||
assert not (set(other) & set(w["unique"])), "他账套(经父键归属)的行泄漏进本账套列表"
|
||||
assert not (set(orphan) & set(w["unique"])), "`alert_id IS NULL` 的行被返回(违反宁少勿错口径)"
|
||||
assert set(w["unique"]) == set(mine)
|
||||
assert w["policy"] == POLICY_TEXT, f"policy 文案被改:{w['policy']}"
|
||||
assert w["note"] and "无父键的历史通知" in w["note"], f"note 文案被改:{w['note']}"
|
||||
|
||||
def test_t5_cross_timestamp_order_newest_first_unchanged(
|
||||
self, client: TestClient, db: Session):
|
||||
"""负对照:跨时间戳仍「新→旧」(时间戳互不相同 ⇒ 无并列,改前改后都应通过)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = _alert(db, kpi.id)
|
||||
|
||||
older = [_log(db, alert.id, created_at=SAME_SECOND - timedelta(days=2)).id
|
||||
for _ in range(10)]
|
||||
newer = [_log(db, alert.id, created_at=SAME_SECOND + timedelta(hours=i + 1)).id
|
||||
for i in range(6)]
|
||||
expected_head = list(reversed(newer)) # 最新(+6h)在最前
|
||||
|
||||
w = _walk(client, token)
|
||||
assert w["total"] == len(older) + len(newer)
|
||||
assert [r["id"] for r in w["rows"]][:6] == expected_head, "跨时间戳未按新→旧排序"
|
||||
assert set(w["unique"]) == set(older) | set(newer)
|
||||
|
||||
def test_t6_page_window_and_deep_page_reachable(self, client: TestClient, db: Session):
|
||||
"""分页窗口本身未变:每页 ≤20 行、深页可读、同一行只在唯一一页出现"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = _alert(db, kpi.id)
|
||||
|
||||
ids = [_log(db, alert.id).id for _ in range(45)]
|
||||
w = _walk(client, token)
|
||||
|
||||
assert w["total"] == 45
|
||||
assert [len(p) for p in w["pages"]] == [20, 20, 5], f"分页窗口异常:{[len(p) for p in w['pages']]}"
|
||||
assert len(w["unique"]) == 45
|
||||
assert set(w["unique"]) == set(ids)
|
||||
|
||||
seen_pages = {}
|
||||
for idx, p in enumerate(w["pages"], start=1):
|
||||
for row_id in p:
|
||||
assert row_id not in seen_pages, f"行 {row_id} 同时出现在页 {seen_pages[row_id]} 与页 {idx}"
|
||||
seen_pages[row_id] = idx
|
||||
Reference in New Issue
Block a user