昨天给「已并入 / 子段」做的收口写成每行去 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 秒上限的灾难探测器 (功能全对、只是慢的那种退化,之前没有任何用例会红)。
255 lines
10 KiB
Go
255 lines
10 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|