删除流量只让数据不可见,磁盘占用从不下降。两处原因叠加: 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 行不受影响;以及降级回旧二进制仍可读写)。
165 lines
5.5 KiB
Go
165 lines
5.5 KiB
Go
package traffic
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// openLegacyIndex builds the index exactly as the pre-reclamation Open did: a
|
|
// plain-path DSN, pragmas via the pool, and auto_vacuum left at its default 0.
|
|
func openLegacyIndex(t *testing.T, dir string) *sql.DB {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Join(dir, "_index"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old, err := sql.Open("sqlite", filepath.Join(dir, "_index", "index.sqlite"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000"} {
|
|
if _, err := old.Exec(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := old.Exec(indexSchema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return old
|
|
}
|
|
|
|
// TestUpgradeFromOldInstall guards the upgrade path. Open now names the database
|
|
// through a file: URI so per-connection pragmas can ride in the DSN, and a
|
|
// driver that did not treat that as a URI would quietly open a file literally
|
|
// named "file:/…" — an empty index, with every recorded exchange apparently
|
|
// gone. The assertions below are what prove that does not happen.
|
|
func TestUpgradeFromOldInstall(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "_index", "index.sqlite")
|
|
old := openLegacyIndex(t, dir)
|
|
if _, err := old.Exec(ftsSchema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 三条历史流量,含一条 legacy path<>'' 的行
|
|
for i, row := range [][]any{
|
|
{"1700000000-0001", "old.example.com", ""},
|
|
{"1700000000-0002", "old.example.com", ""},
|
|
{"1700000000-0003", "legacy.example.com", "legacy.example.com/GET/x"},
|
|
} {
|
|
if _, err := old.Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
|
|
VALUES(?,?,?,'GET','/x','http://x/x',200,'text/html',0,9,?)`, row[0], 1700000000+i, row[1], row[2]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := old.Exec(`INSERT INTO exchange_bodies(id,req_head,req_body,resp_head,resp_body)
|
|
VALUES(?,'GET /x','','HTTP 200','老数据正文')`, row[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := old.Exec(`INSERT INTO ex_fts(rowid,content) VALUES(?,?)`, i+1, "老数据正文 secret-token"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := old.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stat, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// ---- 新版本接管
|
|
tr, err := Open(dir, "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("新版本无法打开旧库: %v", err)
|
|
}
|
|
defer tr.Close()
|
|
|
|
// 1. 必须是同一个文件,不能悄悄开了个新空库
|
|
if st2, err := os.Stat(path); err != nil || st2.Size() == 0 {
|
|
t.Fatalf("原索引文件异常: size=%v err=%v", st2, err)
|
|
}
|
|
if entries, _ := os.ReadDir(filepath.Join(dir, "_index")); len(entries) > 3 {
|
|
for _, e := range entries {
|
|
t.Logf("_index 下: %s", e.Name())
|
|
}
|
|
t.Fatal("_index 下出现了预期外的文件,DSN 可能指向了别的库")
|
|
}
|
|
t.Logf("旧库 %d 字节,新版本接管后仍是同一文件", stat.Size())
|
|
|
|
// 2. 历史数据全部可见
|
|
n, err := tr.Count()
|
|
if err != nil || n != 3 {
|
|
t.Fatalf("Count=(%d,%v),应为 (3,nil) —— 历史流量丢失", n, err)
|
|
}
|
|
// 3. 历史全文索引仍可搜
|
|
if tr.fts {
|
|
rows, err := tr.query("old.example.com", "", "secret-token", 0, 10)
|
|
if err != nil {
|
|
t.Fatalf("历史全文搜索失败: %v", err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("历史全文搜索命中 %d 条,应为 2", len(rows))
|
|
}
|
|
}
|
|
// 4. 历史正文仍可读
|
|
if _, resp, err := tr.Get("1700000000-0001"); err != nil {
|
|
t.Fatalf("读取历史正文失败: %v", err)
|
|
} else if resp == "" {
|
|
t.Fatal("历史响应为空")
|
|
}
|
|
// 5. 旧库不会被误判为已启用增量回收
|
|
if tr.incrementalVacuum {
|
|
t.Fatal("旧库被误判为已启用增量回收")
|
|
}
|
|
// 6. 删除仍然正常工作,且回收流程在旧库上能收敛
|
|
deleted, err := tr.DeleteHostsExact([]string{"old.example.com"})
|
|
if err != nil || deleted != 2 {
|
|
t.Fatalf("DeleteHostsExact=(%d,%v),应为 (2,nil)", deleted, err)
|
|
}
|
|
tr.reaping.Wait()
|
|
if n, err := tr.Count(); err != nil || n != 1 {
|
|
t.Fatalf("删除后 Count=(%d,%v),应为 (1,nil)", n, err)
|
|
}
|
|
// 7. legacy path<>'' 的行没被牵连
|
|
var legacyPath string
|
|
if err := tr.DB().QueryRow(`SELECT path FROM exchanges`).Scan(&legacyPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if legacyPath == "" {
|
|
t.Fatal("legacy 行的 path 被清空了")
|
|
}
|
|
}
|
|
|
|
// TestDowngradeToOldBinary covers a rollback: a database created with
|
|
// auto_vacuum=incremental must stay readable and writable by a build that knows
|
|
// nothing about it. auto_vacuum only changes where SQLite tracks free pages, so
|
|
// the old binary simply goes back to never returning them.
|
|
func TestDowngradeToOldBinary(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tr, err := Open(dir, "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !tr.incrementalVacuum {
|
|
t.Fatal("新库应启用增量回收")
|
|
}
|
|
bulkRecord(tr, "keep.example.com", 5, 100*1024)
|
|
if err := tr.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
old := openLegacyIndex(t, dir) // 旧版本二进制接管
|
|
defer old.Close()
|
|
var n int
|
|
if err := old.QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&n); err != nil || n != 5 {
|
|
t.Fatalf("旧版本读到 (%d,%v),应为 (5,nil)", n, err)
|
|
}
|
|
if _, err := old.Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
|
|
VALUES('x',1,'new.example.com','GET','/x','http://x/x',200,'',0,0,'')`); err != nil {
|
|
t.Fatalf("旧版本写入失败: %v", err)
|
|
}
|
|
if _, err := old.Exec(`DELETE FROM exchanges WHERE host='keep.example.com'`); err != nil {
|
|
t.Fatalf("旧版本删除失败: %v", err)
|
|
}
|
|
}
|