Files
ARTEX/traffic/reclaim_test.go
T
Autumn-27 3227f2e54c fix(traffic): 删除流量后把索引空间真正还给操作系统
删除流量只让数据不可见,磁盘占用从不下降。两处原因叠加:

1. SQLite 删行仅把页挂到 freelist,而索引库建库时未启用 auto_vacuum,
   文件永不收缩。256KB 以下的正文全部内联在这个库里,所以体积主体就在
   这里,且只增不减。
2. ex_fts 是 contentless_delete 全文索引,DELETE 只写 tombstone、不回收
   原 postings,从来没有合并过——删流量反而让索引变大(实测 exchanges
   清零后 ex_fts_data 仍占 3.3MB / 840 行)。

trigram 索引约为正文体积的 2 倍,加上内联正文,抓包量大的实例会为早已
删掉的流量长期占用数倍磁盘:实测抓 6MB 文本正文 → 索引 16.2MB,删光后
仍是 16.2MB。

修复:
- 新建索引库在建表前启用 auto_vacuum=incremental。该 pragma 只在库中尚
  无表时可设,且只有紧随的 VACUUM 才把它写入文件头,两步都必须落在同一
  条连接上,故新增 initIndex 固定一条 *sql.Conn 串起 pragma 与建表。
- 新增 reclaim/reclaimChunk:删除提交后在后台分块执行全文索引增量合并、
  incremental_vacuum 与 wal_checkpoint(TRUNCATE)(不做 TRUNCATE 则空间
  留在 WAL 里、文件大小不变,PASSIVE 也清不掉)。接入 DeleteHost、
  HostDeleteStage.Commit 与 RecoverHostDeleteStages 三条路径。
- 回收分块进行并在块间释放写锁:record() 由 go-mitmproxy 在把响应写回
  客户端之前同步调用,一次性回收数 GB 会连带卡住被测请求。Close 先发停止
  信号,剩余预算留给下次删除,不拖住进程退出。
- 终止条件按「是否有进展」判定:incremental_vacuum 只能释放挪得到文件
  末尾的页,残留少量空闲页是正常结果;另加步数上限兜底。fts5 无法查询
  索引是否已合并完(其特殊 INSERT 自身即计一次 changes),故改为固定合并
  预算,未做完的留给后续删除。
- busy_timeout 改由 DSN 提供:它是每连接设置,原先的 db.Exec 只配到池中
  碰巧被选中的那一条,其余连接在有写者时会立即失败而非等待。驱动按第一个
  '?' 切分 DSN,故数据目录路径含 '?' 时退回裸路径 DSN。

已有实例的索引库仍是旧模式(auto_vacuum 无法事后开启),incremental_vacuum
在其上为空操作,启动时打印提示;此类库的 tombstone 增长已止住,已占体积
待后续「存储压缩」入口一次性回收。

测试:新增 reclaim_test.go(空间回收回归、tombstone 合并、新库模式、旧库
不卡死)与 upgrade_test.go(旧装机升级后同库同数据、全文与正文可用、
legacy path 行不受影响;以及降级回旧二进制仍可读写)。
2026-09-30 01:44:44 -04:00

157 lines
5.2 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 traffic
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// bulkRecord fills the index with inline bodies — the ones that actually make
// index.sqlite grow. Binary content type keeps them out of the full-text index so
// the test stays fast; the FTS side is covered by TestReclaimMergesFTSTombstones.
func bulkRecord(tr *Traffic, host string, n, size int) {
body := []byte(strings.Repeat("A", size))
for i := 0; i < n; i++ {
tr.record(newFlow(host, "GET", fmt.Sprintf("/blob/%d", i), nil, body,
withRespType("application/octet-stream")))
}
}
func TestNewIndexEnablesIncrementalVacuum(t *testing.T) {
tr, _ := openTraffic(t)
if !tr.incrementalVacuum {
t.Fatal("新建索引库未启用增量回收")
}
var mode int
if err := tr.DB().QueryRow(`PRAGMA auto_vacuum`).Scan(&mode); err != nil {
t.Fatal(err)
}
if mode != autoVacuumIncremental {
t.Fatalf("auto_vacuum=%d,应为 %d", mode, autoVacuumIncremental)
}
}
// TestDeleteReclaimsIndexSpace is the regression: deleting traffic used to leave
// index.sqlite at its high-water mark forever, because SQLite only chains freed
// pages onto its freelist and nothing ever returned them to the filesystem.
func TestDeleteReclaimsIndexSpace(t *testing.T) {
tr, _ := openTraffic(t)
const host = "bulk.example.com"
// 30 × 200KB stays under maxInlineBody, so every body lands in the database
// itself rather than the blob store — that is where the growth was invisible.
bulkRecord(tr, host, 30, 200*1024)
grown := tr.indexBytes()
if grown < 5<<20 {
t.Fatalf("索引只有 %d 字节,样本不足以验证回收", grown)
}
if n, err := tr.DeleteHostsExact([]string{host}); err != nil || n != 30 {
t.Fatalf("DeleteHostsExact=(%d,%v),应为 (30,nil)", n, err)
}
tr.reaping.Wait() // 回收在后台分块进行
after := tr.indexBytes()
if after > grown/4 {
t.Fatalf("删除后索引仍占 %d 字节(删除前 %d),空间没有还给文件系统", after, grown)
}
// A handful of pages incremental_vacuum could not move to the end of the file
// is a normal residual; the ~1500 that the deletion freed must be gone.
var free int
if err := tr.DB().QueryRow(`PRAGMA freelist_count`).Scan(&free); err != nil {
t.Fatal(err)
}
if free > 64 {
t.Fatalf("仍有 %d 个空闲页未回收", free)
}
}
// TestReclaimMergesFTSTombstones covers the second half of the leak: ex_fts is a
// contentless_delete index, so a DELETE only writes tombstones. Without a merge
// the index keeps growing on every deletion — deleting traffic made it bigger.
func TestReclaimMergesFTSTombstones(t *testing.T) {
tr, _ := openTraffic(t)
if !tr.fts {
t.Skip("驱动未启用 FTS5")
}
// Deleted in batches, which is what leaves tombstones spread over many
// segments rather than emptying the index in one shot.
for round := 0; round < 4; round++ {
host := fmt.Sprintf("fts%d.example.com", round)
for i := 0; i < 20; i++ {
tr.record(newFlow(host, "GET", fmt.Sprintf("/p/%d", i), nil,
[]byte(strings.Repeat("secret token 中文正文 padding ", 200))))
}
if _, err := tr.DeleteHostsExact([]string{host}); err != nil {
t.Fatal(err)
}
tr.reaping.Wait()
}
var exchanges, segments int
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&exchanges); err != nil {
t.Fatal(err)
}
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM ex_fts_data`).Scan(&segments); err != nil {
t.Fatal(err)
}
if exchanges != 0 {
t.Fatalf("还剩 %d 条流量", exchanges)
}
// A fully merged, empty contentless index keeps only its structure rows.
if segments > 8 {
t.Fatalf("全文索引残留 %d 行段数据,tombstone 未被合并回收", segments)
}
}
// TestReclaimOnLegacyIndexIsHarmless covers installs created before
// auto_vacuum=incremental became the default: incremental_vacuum is a silent
// no-op there, so reclamation must report the situation and finish rather than
// spin or fail. Only a full compaction can convert such a file.
func TestReclaimOnLegacyIndexIsHarmless(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "_index"), 0o755); err != nil {
t.Fatal(err)
}
// Create the tables first, with auto_vacuum left at its default 0 — exactly the
// shape Open used to leave behind.
legacy, err := sql.Open("sqlite", filepath.Join(dir, "_index", "index.sqlite"))
if err != nil {
t.Fatal(err)
}
if _, err := legacy.Exec(indexSchema); err != nil {
t.Fatal(err)
}
if err := legacy.Close(); err != nil {
t.Fatal(err)
}
tr, err := Open(dir, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tr.Close() })
if tr.incrementalVacuum {
t.Fatal("旧库不应报告已启用增量回收")
}
const host = "legacy.example.com"
bulkRecord(tr, host, 8, 200*1024)
if n, err := tr.DeleteHostsExact([]string{host}); err != nil || n != 8 {
t.Fatalf("DeleteHostsExact=(%d,%v),应为 (8,nil)", n, err)
}
tr.reaping.Wait() // 必须收敛,不能卡在预算里
// The freelist stays populated: that is the whole reason a compaction entry
// point is needed for pre-existing databases.
var free int
if err := tr.DB().QueryRow(`PRAGMA freelist_count`).Scan(&free); err != nil {
t.Fatal(err)
}
if free == 0 {
t.Fatal("旧库居然回收了空闲页,说明测试没有真的构造出旧库")
}
}