From 3227f2e54c7f5f0b02c772683210844119f342fc Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:44:44 -0400 Subject: [PATCH] =?UTF-8?q?fix(traffic):=20=E5=88=A0=E9=99=A4=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E5=90=8E=E6=8A=8A=E7=B4=A2=E5=BC=95=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E8=BF=98=E7=BB=99=E6=93=8D=E4=BD=9C=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除流量只让数据不可见,磁盘占用从不下降。两处原因叠加: 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 行不受影响;以及降级回旧二进制仍可读写)。 --- CHANGELOG.md | 8 ++ traffic/reclaim_test.go | 156 +++++++++++++++++++++++ traffic/traffic.go | 272 +++++++++++++++++++++++++++++++++++++--- traffic/upgrade_test.go | 164 ++++++++++++++++++++++++ 4 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 traffic/reclaim_test.go create mode 100644 traffic/upgrade_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc48ed..e2a1c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 流量 + +#### 修复的问题 + +- **修复删除流量后磁盘空间不被释放**:删除只让数据不可见,空间一直留在索引文件里。SQLite 删行仅把页挂到 freelist,而索引库建库时没有启用 `auto_vacuum`,文件永不收缩;同时 `ex_fts` 是 `contentless_delete` 全文索引,`DELETE` 只写 tombstone 而不回收原 postings,不合并就永久累积——删流量反而让索引变大。由于 256KB 以下的正文全部内联在这个库里,加上 trigram 索引约为正文体积的 2 倍,抓包量大的实例会为早已删掉的流量长期占用数倍磁盘(实测抓 6MB 正文 → 索引 16MB,删光后仍是 16MB)。现在新建索引库直接启用 `auto_vacuum=incremental`,每次删除提交后在后台分块执行「全文索引增量合并 + `incremental_vacuum` + `wal_checkpoint(TRUNCATE)`」,逐步把空间还给操作系统(同一场景删除后回落到 104KB)。回收分块进行并在块间释放写锁,不阻塞流量录制;进程退出时立即让出,剩余工作在下次删除时续做。 + +> 升级说明:`auto_vacuum` 只能在建库时设定,因此**已有实例的索引库仍是旧模式**,`incremental_vacuum` 在其上是空操作——启动时会打印一行提示。这类库升级后 tombstone 的持续增长已经止住(全文索引合并照常执行),但已占用的体积需要后续提供的「存储压缩」入口一次性回收。 + ### LLM #### 修复的问题 diff --git a/traffic/reclaim_test.go b/traffic/reclaim_test.go new file mode 100644 index 0000000..ec22920 --- /dev/null +++ b/traffic/reclaim_test.go @@ -0,0 +1,156 @@ +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("旧库居然回收了空闲页,说明测试没有真的构造出旧库") + } +} diff --git a/traffic/traffic.go b/traffic/traffic.go index d8cc2b4..587e5d9 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -102,6 +102,30 @@ const ( // maxBlobRead caps one traffic_blob read so paging through a large body never // floods the agent's context. maxBlobRead = 8 * 1024 + // autoVacuumIncremental is SQLite's numeric value for auto_vacuum=incremental. + autoVacuumIncremental = 2 + // reclaimChunkPages bounds how much index space one locked step returns to the + // filesystem (pages are 4KiB, so ~32MB). Reclamation holds the write lock, and + // record() runs before go-mitmproxy replies to the client, so an unbounded + // pass would stall the very requests being recorded. + reclaimChunkPages = 8192 + // reclaimMergePages bounds the full-text merge done alongside each chunk. + // Deleting from a contentless_delete index only writes tombstones; merging is + // what discards them, and left undone the index grows on every deletion. + reclaimMergePages = 256 + // reclaimMergeSteps bounds how many merges one reclamation performs. fts5 + // offers no way to ask whether an index has settled — its special INSERT + // registers a row change of its own, so change counting cannot tell a real + // merge from a no-op — so the budget is simply spent down, and whatever is + // left over falls to the next deletion. + reclaimMergeSteps = 16 + // reclaimMaxSteps backstops the loop. Both merging and incremental_vacuum are + // documented to stop making progress eventually, but a reclamation that cannot + // converge must give up rather than spin holding the write lock. + reclaimMaxSteps = 512 + // reclaimBudget caps one background reclamation. Deleting a busy host can free + // gigabytes, and the next deletion resumes wherever this one stopped. + reclaimBudget = 5 * time.Minute ) // TrafficSearchDescription is persisted into the tool catalog for new and @@ -121,9 +145,22 @@ type Traffic struct { // build without FTS5: recording and metadata search still work, body search // degrades to unsupported rather than erroring. fts bool - // reaping tracks background reclamation of legacy trees, so shutdown and tests - // can wait for it instead of racing it. + // reaping tracks background reclamation of legacy trees and index space, so + // shutdown and tests can wait for it instead of racing it. reaping sync.WaitGroup + // incrementalVacuum reports whether the index can return freed pages to the + // filesystem on its own. False on a database created before this was the + // default: PRAGMA incremental_vacuum is a silent no-op there, so deletions + // reclaim nothing until an explicit full compaction converts the file. + incrementalVacuum bool + // reclaiming keeps a single background reclamation in flight. Concurrent ones + // would only contend for wmu, never finish sooner. + reclaiming atomic.Bool + // closed is shut by Close so a reclamation abandons its remaining budget + // instead of holding shutdown open for minutes. Nil on the zero value, which + // stopping() treats as "not closing". + closed chan struct{} + closeOnce sync.Once // pass is the set of hosts whose MITM interception failed for a proxy/protocol // reason; connections to them are tunneled transparently (fail-open) so the // request still reaches the target — unrecorded — instead of being killed. @@ -142,26 +179,27 @@ func Open(dir, addr string) (*Traffic, error) { return nil, err } } - db, err := sql.Open("sqlite", filepath.Join(dir, "_index", "index.sqlite")) + // busy_timeout is a per-connection setting, so it belongs in the DSN rather + // than in a one-off Exec: the pool opens connections on demand, and an Exec + // only configures whichever one happened to serve it — leaving every other + // connection to fail instantly the moment a writer holds the database. + // The driver splits the DSN at the first '?', so a data directory containing + // one would silently name a different file; that path falls back to a bare + // DSN, where initIndex still applies the pragmas to its own connection. + index := filepath.Join(dir, "_index", "index.sqlite") + dsn := index + if !strings.ContainsRune(index, '?') { + dsn = "file:" + index + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" + } + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } - for _, p := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000"} { - if _, err := db.Exec(p); err != nil { - db.Close() - return nil, err - } - } - if _, err := db.Exec(indexSchema); err != nil { + t := &Traffic{dir: dir, addr: addr, db: db, closed: make(chan struct{})} + if err := t.initIndex(); err != nil { db.Close() return nil, err } - t := &Traffic{dir: dir, addr: addr, db: db} - if _, err := db.Exec(ftsSchema); err != nil { - log.Printf("[traffic] 全文索引不可用,正文搜索将被禁用(元数据搜索不受影响):%v", err) - } else { - t.fts = true - } p, err := mproxy.NewProxy(&mproxy.Options{ Addr: addr, @@ -193,6 +231,66 @@ func Open(dir, addr string) (*Traffic, error) { return t, nil } +// initIndex applies the schema on a single pinned connection. Pinning is what +// makes auto_vacuum reliable: it can only be set while the database still holds +// no tables, and only the VACUUM that follows writes it into the file header — +// two steps a pooled *sql.DB is free to route to different connections, which +// would silently drop the setting. +// +// auto_vacuum=incremental is what lets a deletion hand freed pages back to the +// filesystem. Without it SQLite merely chains them onto its freelist, so the +// index file never shrinks no matter how much traffic is deleted — and since +// every body below maxInlineBody lives in that file, plus a trigram index +// roughly twice the size of the text it covers, a capture-heavy install ends up +// holding gigabytes for traffic it no longer has. On a database that already has +// tables the pragma is a documented no-op, so installs created before this keep +// auto_vacuum=0 until a full compaction converts the file; incrementalVacuum +// records that so reclaim can say so instead of pretending to reclaim. +func (t *Traffic) initIndex() error { + ctx := context.Background() + conn, err := t.db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + // Re-applied rather than left to the DSN so the bare-DSN fallback in Open is + // still correct: journal_mode persists in the file header, which is what every + // later connection reads. + for _, p := range []string{`PRAGMA journal_mode=WAL`, `PRAGMA busy_timeout=5000`} { + if _, err := conn.ExecContext(ctx, p); err != nil { + return err + } + } + var tables int + if err := conn.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type='table'`).Scan(&tables); err != nil { + return err + } + if tables == 0 { + for _, p := range []string{`PRAGMA auto_vacuum=incremental`, `VACUUM`} { + if _, err := conn.ExecContext(ctx, p); err != nil { + return err + } + } + } + var mode int + if err := conn.QueryRowContext(ctx, `PRAGMA auto_vacuum`).Scan(&mode); err != nil { + return err + } + t.incrementalVacuum = mode == autoVacuumIncremental + if !t.incrementalVacuum { + log.Printf("[traffic] 索引库未启用增量回收(auto_vacuum=%d):删除流量不会缩小 index.sqlite,需要执行一次存储压缩来转换", mode) + } + if _, err := conn.ExecContext(ctx, indexSchema); err != nil { + return err + } + if _, err := conn.ExecContext(ctx, ftsSchema); err != nil { + log.Printf("[traffic] 全文索引不可用,正文搜索将被禁用(元数据搜索不受影响):%v", err) + return nil + } + t.fts = true + return nil +} + // hostOnly strips an optional :port, so passthrough keys match whether the host // arrives as "example.com:443" (CONNECT) or "example.com" (request URL). func hostOnly(hostport string) string { @@ -262,11 +360,27 @@ func (t *Traffic) Start() error { return t.proxy.Start() } // Close waits for background tree reclamation to finish before closing the // index, so shutdown never leaves a goroutine unlinking files out from under a -// removed data directory. +// removed data directory. Index-space reclamation is signalled to stop first: +// it holds a whole minutes-long budget, and finishing it is never worth delaying +// shutdown for — the next deletion resumes it. func (t *Traffic) Close() error { + if t.closed != nil { + t.closeOnce.Do(func() { close(t.closed) }) + } t.reaping.Wait() return t.db.Close() } + +// stopping reports whether Close has been called. A nil channel (the zero value) +// is never ready, so this reads as "not closing" without a separate guard. +func (t *Traffic) stopping() bool { + select { + case <-t.closed: + return true + default: + return false + } +} func (t *Traffic) DB() *sql.DB { return t.db } // sink is the go-mitmproxy addon that records completed exchanges. @@ -944,6 +1058,7 @@ func (t *Traffic) DeleteHost(host string) (int64, error) { } t.reapStage(stageDir) if n > 0 { + t.reclaim() if err := t.gcBlobs(); err != nil { return n, err } @@ -1345,6 +1460,7 @@ func (t *Traffic) RecoverHostDeleteStages(archiveCommitted func(int64, int64) (b needsGC = true } if needsGC { + t.reclaim() if err := t.gcBlobs(); err != nil { errs = append(errs, err) } @@ -1436,6 +1552,7 @@ func (s *HostDeleteStage) Commit() error { // it now runs in the background, while collection below only needs them to be // out of the live tree — which the staging rename already guaranteed. s.traffic.reapStage(s.stageDir) + s.traffic.reclaim() var errs []error if err := s.traffic.gcBlobs(); err != nil { errs = append(errs, fmt.Errorf("回收流量 blob: %w", err)) @@ -1445,6 +1562,127 @@ func (s *HostDeleteStage) Commit() error { return errors.Join(errs...) } +// indexBytes is the index's real footprint on disk: the database plus its +// write-ahead log and shared-memory file, since those are what an operator sees +// the data directory holding. +func (t *Traffic) indexBytes() int64 { + base := filepath.Join(t.dir, "_index", "index.sqlite") + var total int64 + for _, p := range []string{base, base + "-wal", base + "-shm"} { + if st, err := os.Stat(p); err == nil { + total += st.Size() + } + } + return total +} + +// reclaim hands space freed by a deletion back to the filesystem. Deleting rows +// only makes them invisible: SQLite chains their pages onto a freelist, and +// deleting from the contentless_delete full-text index writes tombstones rather +// than removing the postings they hide. Neither shrinks a single byte on disk, +// and since bodies below maxInlineBody live in that same file, an install that +// captures heavily ends up holding far more than the traffic it still has. +// +// The work is done in the background, in bounded steps that drop the write lock +// between them, because it is proportional to what was deleted — freeing several +// gigabytes under one lock would stall record(), which go-mitmproxy calls before +// it replies to the client, and so would stall the requests being recorded. +// Safe to call with wmu held: the background pass simply waits for it. +// +// Best-effort throughout. Failing to reclaim costs disk space, never +// correctness, so errors are logged and the next deletion resumes the work. +func (t *Traffic) reclaim() { + if !t.reclaiming.CompareAndSwap(false, true) { + return // one pass at a time; a second would only contend for the lock + } + t.reaping.Go(func() { + defer t.reclaiming.Store(false) + // One pinned connection for the whole pass: a long reclamation issues + // hundreds of statements, and letting the pool hand each one a different + // connection would both churn connections and split the freelist readings + // that decide when to stop away from the vacuum they measure. + ctx := context.Background() + conn, err := t.db.Conn(ctx) + if err != nil { + log.Printf("[traffic] 回收索引空间失败(获取连接):%v", err) + return + } + defer conn.Close() + deadline := time.Now().Add(reclaimBudget) + merges := reclaimMergeSteps + for step := 0; ; step++ { + t.wmu.Lock() + progressed, err := t.reclaimChunk(ctx, conn, &merges) + t.wmu.Unlock() + if err != nil { + log.Printf("[traffic] 回收索引空间失败:%v", err) + return + } + if !progressed { + break + } + if t.stopping() { + return // shutdown must not wait out the remaining budget + } + if step+1 >= reclaimMaxSteps { + log.Printf("[traffic] 索引空间回收未做完(已用满 %d 步上限),下次删除时继续", reclaimMaxSteps) + return + } + if time.Now().After(deadline) { + log.Printf("[traffic] 索引空间回收未做完(已用满 %s 预算),下次删除时继续", reclaimBudget) + return + } + } + // Truncating the log is what makes the reclamation visible on disk: in WAL + // mode the freed pages are recorded there first, and a PASSIVE checkpoint + // would leave the log itself sitting at its high-water mark. + t.wmu.Lock() + defer t.wmu.Unlock() + if _, err := conn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + log.Printf("[traffic] 截断 WAL 失败:%v", err) + } + }) +} + +// reclaimChunk does one bounded step and reports whether it made progress, which +// is what the caller loops on. merges carries the remaining full-text merge +// budget and is spent down here. Callers must hold wmu. +func (t *Traffic) reclaimChunk(ctx context.Context, conn *sql.Conn, merges *int) (bool, error) { + progressed := false + if t.fts && *merges > 0 { + // A negative rank is fts5's page budget for one incremental merge. + if _, err := conn.ExecContext(ctx, `INSERT INTO ex_fts(ex_fts, rank) VALUES('merge', ?)`, -reclaimMergePages); err != nil { + return false, fmt.Errorf("合并全文索引: %w", err) + } + *merges-- + progressed = true + } + if !t.incrementalVacuum { + // incremental_vacuum is a silent no-op on a database created with + // auto_vacuum=0; only a full compaction can convert one. Merging the + // full-text index above still pays off, so stop here rather than earlier. + return progressed, nil + } + var before, after int + if err := conn.QueryRowContext(ctx, `PRAGMA freelist_count`).Scan(&before); err != nil { + return false, err + } + if before == 0 { + return progressed, nil + } + // The budget is a constant and PRAGMA arguments cannot be bound as parameters. + if _, err := conn.ExecContext(ctx, fmt.Sprintf(`PRAGMA incremental_vacuum(%d)`, reclaimChunkPages)); err != nil { + return false, fmt.Errorf("回收索引空闲页: %w", err) + } + if err := conn.QueryRowContext(ctx, `PRAGMA freelist_count`).Scan(&after); err != nil { + return false, err + } + // Progress, not an empty freelist, is the right stopping condition: + // incremental_vacuum can only release pages it manages to move to the end of + // the file, so a residual freelist it cannot shrink is a normal outcome. + return progressed || after < before, nil +} + // gcBlobs removes blobs that no remaining exchange references. Live references // come from blob_refs, so collection is one query plus a sweep of the blob // directory — no exchange body is ever read. Emptied bucket directories are diff --git a/traffic/upgrade_test.go b/traffic/upgrade_test.go new file mode 100644 index 0000000..dd287aa --- /dev/null +++ b/traffic/upgrade_test.go @@ -0,0 +1,164 @@ +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) + } +}