fix(analytics): id-tiebreak snapshot ordering + rates/cn_day tests + breakdown ValueError
This commit is contained in:
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
@@ -31,7 +31,7 @@ def diff_snapshots(rows: list[dict]) -> list[dict]:
|
||||
|
||||
out: list[dict] = []
|
||||
for group in parts.values():
|
||||
group.sort(key=lambda r: r["created_at"])
|
||||
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
|
||||
prev = {k: 0 for k in _COUNTS}
|
||||
for r in group:
|
||||
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
|
||||
@@ -74,7 +74,7 @@ def _sum_deltas(deltas: list[dict]) -> dict:
|
||||
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
|
||||
cols = (
|
||||
H.device_id, H.epoch_id, E.event, H.created_at,
|
||||
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
|
||||
H.app_ver, H.oem, H.os,
|
||||
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
|
||||
)
|
||||
@@ -83,8 +83,10 @@ def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dic
|
||||
.where(H.created_at >= date_from, H.created_at < date_to)
|
||||
).mappings().all()
|
||||
|
||||
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
|
||||
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
|
||||
sub = (
|
||||
select(H.device_id, H.epoch_id, E.event, func.max(H.created_at).label("mx"))
|
||||
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
|
||||
.join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at < date_from)
|
||||
.group_by(H.device_id, H.epoch_id, E.event)
|
||||
@@ -92,13 +94,7 @@ def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dic
|
||||
)
|
||||
baseline = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id).join(
|
||||
sub,
|
||||
and_(
|
||||
sub.c.device_id == H.device_id,
|
||||
sub.c.epoch_id == H.epoch_id,
|
||||
sub.c.event == E.event,
|
||||
sub.c.mx == H.created_at,
|
||||
),
|
||||
sub, sub.c.max_id == H.id
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
@@ -128,7 +124,8 @@ def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
|
||||
|
||||
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
|
||||
assert dim in ("event", "app_ver", "oem")
|
||||
if dim not in ("event", "app_ver", "oem"):
|
||||
raise ValueError(f"invalid dim: {dim!r}")
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_key: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.admin.repositories.analytics_health import diff_snapshots
|
||||
from app.admin.repositories.analytics_health import _cn_day, _rates, diff_snapshots
|
||||
|
||||
|
||||
def _row(device, epoch, event, ts, **cum) -> dict:
|
||||
@@ -52,3 +52,37 @@ def test_diff_clamps_negative_on_reorder() -> None:
|
||||
]
|
||||
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
|
||||
assert out[1]["d_attempted"] == 0
|
||||
|
||||
|
||||
def test_rates_computes_both_formulas() -> None:
|
||||
out = _rates({"attempted": 150, "drop_capture": 0, "delivered": 140, "drop_undelivered": 10})
|
||||
assert out["track_success_rate"] == 1.0
|
||||
assert out["report_success_rate"] == 140 / 150
|
||||
|
||||
|
||||
def test_rates_zero_denominator_yields_none() -> None:
|
||||
out = _rates({"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0})
|
||||
assert out["track_success_rate"] is None
|
||||
assert out["report_success_rate"] is None
|
||||
|
||||
|
||||
def test_cn_day_beijing_boundary() -> None:
|
||||
# UTC 15:59 → 北京 23:59 同日;UTC 16:00 → 北京 次日 00:00
|
||||
assert _cn_day(datetime(2026, 7, 1, 15, 59, tzinfo=timezone.utc)) == "2026-07-01"
|
||||
assert _cn_day(datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc)) == "2026-07-02"
|
||||
|
||||
|
||||
def test_diff_same_timestamp_ordered_by_id() -> None:
|
||||
ts = datetime(2026, 7, 1, 1, 0, tzinfo=timezone.utc)
|
||||
# 相同 created_at,乱序传入(id=2 在前);应按 id 排序 → id=1(cum100) 在前
|
||||
rows = [
|
||||
{"id": 2, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
|
||||
"app_ver": "v", "oem": "o", "os": "s",
|
||||
"attempted": 150, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
|
||||
{"id": 1, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
|
||||
"app_ver": "v", "oem": "o", "os": "s",
|
||||
"attempted": 100, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
|
||||
]
|
||||
out = diff_snapshots(rows)
|
||||
assert out[0]["d_attempted"] == 100 # id=1 sorts first → its cumulative
|
||||
assert out[1]["d_attempted"] == 50 # id=2 second → 150-100
|
||||
|
||||
Reference in New Issue
Block a user