Files
rond-with-you/internal/web/hidden_visit_test.go
T
tamakyi c9ebb3aebd 修性能回归:把「已并入 / 子段」判据做成派生列(visits.is_merged)
昨天给「已并入 / 子段」做的收口写成每行去 entity_raw 判
`raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL`,而 entity_raw.raw 是
**TOAST 过的整行 Core Data JSON**:一次探针要取回并解析整行。地点库「每个地点 × 5 个子查询」
的量级下 —— 实测 /places 70.1 秒、首页 43.6 秒才出页面(而库里没有任何一条 SQL 超过 1 秒:
时间全花在成千上万次「取整行再解析」上,进程 CPU 只用了 3s/135s)。

这批「渲染若干页面」的布局用例因此从 20 秒涨到 226~309 秒,整套验收从 6 分钟变 20+ 分钟。

- schema.sql:加派生列 visits.is_merged + 回填 + 给回填用的部分索引
  (只索引确实带合并标记的那几十行,不用取回 raw)。
- stats.SoloVisitSQL 改成读这一列(判据仍然只此一份,读侧全部经由它)。
- 写入侧跟着刷:ingest 在 insertEntityRaw 之后、backup_merge 的 recountPlaces 里,
  共用 stats.RefreshMergedFlagsSQL。
- 直接改留档里的合并标记(工具脚本 / 用例)之后也要刷一次,schema.sql 与函数注释都写明了。

实测:/places 70084ms → 206ms,/ 43633ms → 698ms(比「关掉判据」的对照还快)。
新增 page_render_budget_test.go:给 7 个关键页卡 10 秒上限的灾难探测器
(功能全对、只是慢的那种退化,之前没有任何用例会红)。
2026-09-30 16:18:52 +08:00

255 lines
10 KiB
Go
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.
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/db"
"rond-with-you/internal/stats"
)
// 真机里「不独立成立」的到访(已并入别处 ZMERGEDTO / 某个父段的子段 ZPARENT)只在**读**的一侧收口:
// 时间线、统计、地点计数不显示它们,编辑页照样看得到(打标),而导出的包一个字节都不能少
// ——rond 靠这些行认「这次合并发生过」,删掉或丢掉就是毁真机的记录。
// exportVisitRow 在导出包里按 UUID 找一行到访,返回它在包里的 Z_PK 与 ZMERGEDTO。
// 必须按 UUID 找:(编号也要对,但按编号找会因为对齐而误判)。
func exportVisitRow(t *testing.T, edb *sql.DB, uuidHex string) (int, sql.NullInt64, bool) {
t.Helper()
var pk int
var merged sql.NullInt64
err := edb.QueryRow(`SELECT Z_PK, ZMERGEDTO FROM ZVISIT WHERE hex(ZIDENTIFIER_)=?`,
strings.ToUpper(uuidHex)).Scan(&pk, &merged)
if err == sql.ErrNoRows {
return 0, merged, false
}
if err != nil {
t.Fatalf("查导出包失败: %v", err)
}
return pk, merged, true
}
func TestHiddenVisitStaysInExportButLeavesReads(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv := f.srv
// 夹具导入的是真机包,本来就带这类行;没有的话下面的增量断言就不成立
var fixtureHidden int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT'
AND (raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL)`, dsID).Scan(&fixtureHidden); err != nil {
t.Fatal(err)
}
t.Logf("夹具自带「不独立成立」的到访 %d 条", fixtureHidden)
// 挑一条正常到访,把它标成「已并入 #target」
var victim, target int
var placeID int64
var durMin int
if err := pg.QueryRowContext(ctx, `SELECT v.src_pk, COALESCE(v.place_id,0), COALESCE(v.duration_min,0)
FROM visits v JOIN entity_raw er ON er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
WHERE v.dataset_id=$1 AND v.place_id IS NOT NULL
AND er.raw->>'ZMERGEDTO' IS NULL AND er.raw->>'ZPARENT' IS NULL
ORDER BY v.src_pk DESC LIMIT 1`, dsID).Scan(&victim, &placeID, &durMin); err != nil {
t.Fatalf("挑不出可标记的到访: %v", err)
}
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM visits
WHERE dataset_id=$1 AND src_pk<>$2 ORDER BY src_pk LIMIT 1`, dsID, victim).Scan(&target); err != nil {
t.Fatal(err)
}
victimUUID := visitUUIDHex(t, srv, ctx, dsID, victim)
if victimUUID == "" {
t.Fatal("挑中的到访没有留档 UUID,用例前提不成立")
}
// 标记前:访客侧看得到它,导出包里它没有合并标记
flt := stats.Filter{DatasetID: dsID}
_, totalBefore, err := srv.q.Visits(ctx, flt, 5, 0, "desc")
if err != nil {
t.Fatal(err)
}
var pcBefore, dwellBefore int64
if err := pg.QueryRowContext(ctx, `SELECT visit_count, dwell_minutes FROM places WHERE id=$1`, placeID).
Scan(&pcBefore, &dwellBefore); err != nil {
t.Fatal(err)
}
var mvCount int
var mvKm float64
pg.QueryRowContext(ctx, `SELECT count(*), COALESCE(sum(distance_km),0) FROM movements WHERE dataset_id=$1`, dsID).
Scan(&mvCount, &mvKm)
var dsCountBefore int
pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCountBefore)
edbBefore := openExport(t, f.export(t))
if _, merged, ok := exportVisitRow(t, edbBefore, victimUUID); !ok {
t.Fatalf("标记前导出包里就找不到 #%d", victim)
} else if merged.Valid {
t.Fatalf("标记前 ZMERGEDTO 就不该有值,实际 %v", merged.Int64)
}
// 标记成「已并入 #target」。读侧判据取的是派生列 visits.is_merged,
// 所以直接改留档之后必须按生产路径刷一次(导入 / 合并也是这么刷的)。
if _, err := pg.ExecContext(ctx, `UPDATE entity_raw
SET raw = jsonb_set(raw, '{ZMERGEDTO}', to_jsonb($3::int), true)
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, victim, target); err != nil {
t.Fatalf("标记失败: %v", err)
}
if _, err := pg.ExecContext(ctx, stats.RefreshMergedFlagsSQL, dsID); err != nil {
t.Fatalf("刷新合并标记失败: %v", err)
}
// 1) 访客侧:时间线少了一条,且被标记的那条不再出现
rows, totalAfter, err := srv.q.Visits(ctx, flt, 5, 0, "desc")
if err != nil {
t.Fatal(err)
}
if totalAfter != totalBefore-1 {
t.Errorf("时间线总数应为 %d,实际 %d", totalBefore-1, totalAfter)
}
for _, r := range rows {
if r.SrcPK == victim {
t.Errorf("被标记为「已并入」的 #%d 仍出现在时间线里", victim)
}
}
// 地点详情页的到访列表、收藏列表走的是另外两条自建 where,也要一起收口
if vs, err := srv.q.VisitsByPlace(ctx, dsID, placeID, 500); err != nil {
t.Fatal(err)
} else {
for _, v := range vs {
if v.SrcPK == victim {
t.Errorf("地点页的到访列表里仍有 #%d", victim)
}
}
}
// 2) 地点的到访数 / 停留跟着少(编辑页删改之后也会重算,口径必须一致)
if err := srv.refreshPlaceAgg(ctx, pg, placeID); err != nil {
t.Fatalf("重算地点聚合失败: %v", err)
}
var pcAfter, dwellAfter int64
if err := pg.QueryRowContext(ctx, `SELECT visit_count, dwell_minutes FROM places WHERE id=$1`, placeID).
Scan(&pcAfter, &dwellAfter); err != nil {
t.Fatal(err)
}
capped := durMin
if capped > 10080 {
capped = 10080
}
if pcAfter != pcBefore-1 || dwellAfter != dwellBefore-int64(capped) {
t.Errorf("地点聚合应为 %d 次 / %d 分,实际 %d 次 / %d 分",
pcBefore-1, dwellBefore-int64(capped), pcAfter, dwellAfter)
}
// 3) 位移(里程 / 轨迹)不动:那条到访不单独显示,但它参与过的行程真实发生过
var mvCountAfter int
var mvKmAfter float64
pg.QueryRowContext(ctx, `SELECT count(*), COALESCE(sum(distance_km),0) FROM movements WHERE dataset_id=$1`, dsID).
Scan(&mvCountAfter, &mvKmAfter)
if mvCountAfter != mvCount || mvKmAfter != mvKm {
t.Errorf("位移不该受影响:%d 段 / %.3f km → %d 段 / %.3f km", mvCount, mvKm, mvCountAfter, mvKmAfter)
}
// 数据集计数是后台口径(原始行数),不跟着访客口径变
var dsCountAfter int
pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCountAfter)
if dsCountAfter != dsCountBefore {
t.Errorf("datasets.visit_count 是后台口径,不该变成 %d(原 %d)", dsCountAfter, dsCountBefore)
}
// 4) 标了之后仍然导得出去,而且这一行连合并标记一起原样在包里
edbAfter := openExport(t, f.export(t))
pk, merged, ok := exportVisitRow(t, edbAfter, victimUUID)
if !ok {
t.Fatalf("导出包里丢了被标记的到访 #%d —— 读侧收口绝不能影响导出", victim)
}
if !merged.Valid || int(merged.Int64) != target {
t.Errorf("导出包里 ZMERGEDTO 应为 #%d,实际 %v", target, merged)
}
if pk != victim {
t.Errorf("包内编号应为 %d,实际 %d", victim, pk)
}
// 5) 管理端(数据编辑页)照样看得到它,并带上说明——不然站长会以为数据丢了
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", fmt.Sprintf("/admin/edit?vq=%d", victim), nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf("已并入 #%d", target)) {
t.Errorf("编辑页没有给 #%d 打上「已并入 #%d」的标记", victim, target)
}
}
// TestHiddenVisitBackfillFixesStaleAggregates 存量库的那段回填(schema.sql)必须把旧口径的数字拉正。
//
// 只在读的一侧收口的话,已经算好的 places.visit_count / dwell_minutes 会一直偏大
// (要等到有人编辑那个地点才重算),所以迁移里带一段幂等的重算。这里真的跑一次 Migrate,
// 而不是另抄一份 SQL——抄一份就等于没测那段。
func TestHiddenVisitBackfillFixesStaleAggregates(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 造出「旧口径」的偏大值:假设那条已并入的到访也被算进去了
var placeID int64
var want int64
if err := pg.QueryRowContext(ctx, `SELECT p.id, p.visit_count FROM places p
WHERE p.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v JOIN entity_raw er
ON er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
WHERE v.place_id=p.id AND er.raw->>'ZMERGEDTO' IS NOT NULL) LIMIT 1`, dsID).Scan(&placeID, &want); err != nil {
t.Skipf("夹具里没有「已并入」的到访(%v)", err)
}
if _, err := pg.ExecContext(ctx, `UPDATE places SET visit_count=$2, dwell_minutes=dwell_minutes+1 WHERE id=$1`,
placeID, want+1); err != nil {
t.Fatal(err)
}
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("跑迁移失败: %v", err)
}
var got int64
if err := pg.QueryRowContext(ctx, `SELECT visit_count FROM places WHERE id=$1`, placeID).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("回填后应回到 %d 次,实际 %d 次——存量库的旧口径没被拉正", want, got)
}
}
// TestSoloVisitCondSharedAndOffTheExport 静态守卫:这条判据只能有一份,而且不许出现在导出 / 差异侧。
//
// 分散手抄迟早漂移(区域限制那套就漏过一次,表现是「到访藏了里程还在」);
// 反过来,导出与合并一旦用了它,包里就会少掉真机自己的行。
func TestSoloVisitCondSharedAndOffTheExport(t *testing.T) {
root := moduleRoot(t)
read := func(rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("读 %s 失败: %v", rel, err)
}
return string(b)
}
// 导出与差异必须看到全部行
for _, rel := range []string{"internal/rond/export.go", "internal/web/backup_diff.go"} {
if strings.Contains(read(rel), "SoloVisitSQL") {
t.Errorf("%s 不该用 SoloVisitSQL:导出与差异要看到真机的全部行", rel)
}
}
// 读的一侧共用同一份判据
for _, rel := range []string{
"internal/stats/stats.go", "internal/ingest/ingest.go",
"internal/web/handlers_public.go", "internal/web/handlers_topics.go",
"internal/web/handlers_edit.go", "internal/web/backup_merge.go",
} {
if !strings.Contains(read(rel), "SoloVisitSQL") {
t.Errorf("%s 没有用 SoloVisitSQL:到访数与时间线会对不上", rel)
}
}
}