package documents // 本文件是**引用计数 × 回收**的端到端验收(必测 4:引用计数正确性):一条真实 // 链路 —— `documents.NewRefCounter` 注入 `history.Store` 之后,「两条消息引用 ⇒ // ref_count = 2;删一条 ⇒ 1(仍不删);删最后一条 ⇒ 0,超期后才可删」。 // // 为什么必须有它:RefCounter 与 history 是两份代码,各自单测都绿仍可能凑不出一 // 条正确的计数(例如抽取结论回写把同一份文档又 +1 一次,文件就**永远**回收不掉)。 // 这里跑的是完整链路 + 真实 SQLite,因此这条不变量是机器证明的。 import ( "context" "os" "path/filepath" "strconv" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // refCountOf 读一份文档当前的引用计数。 func refCountOf(t *testing.T, f *gcFixture, name string) int64 { t.Helper() var refs int64 if err := f.db.QueryRowContext(context.Background(), `SELECT ref_count FROM documents WHERE name = ?`, name).Scan(&refs); err != nil { t.Fatalf("读 ref_count(%q): %v", name, err) } return refs } // TestRefCountEndToEndWithMessagesAndGC 覆盖必测 4 的完整链路。 func TestRefCountEndToEndWithMessagesAndGC(t *testing.T) { t.Parallel() ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) f := newGCFixture(t, now) // 时间往后拨 30 天:保留期(7 天)早已过去,于是「还留着」只可能因为仍被引用。 later := func() time.Time { return now.Add(30 * 24 * time.Hour) } gc := NewGC(GCConfig{ DB: f.db, DataDir: f.dataDir, UploadsDir: f.uploads, Interval: DefaultGCInterval, Retention: DefaultRetention, Now: later, }) // 真实上传:原件落盘 + 元数据行入表。 store := NewStore(StoreConfig{ UploadsDir: f.uploads, Meta: MetadataWriterFor(filepath.Join(f.dataDir, "gc.db"), nil), }) ref, err := store.Save(ctx, "端到端.txt", []byte("端到端引用计数的正文")) if err != nil { t.Fatalf("Save: %v", err) } sidecar := filepath.Join(f.extracted, ref.Name+sidecarSuffix) if err := os.MkdirAll(f.extracted, sidecarDirMode); err != nil { t.Fatalf("建 sidecar 目录: %v", err) } if err := os.WriteFile(sidecar, []byte("抽取正文"), sidecarFileMode); err != nil { t.Fatalf("写 sidecar: %v", err) } hist, err := history.NewStore(ctx, f.db, nil, history.WithDocumentRefs(NewRefCounter(nil))) if err != nil { t.Fatalf("history.NewStore: %v", err) } raw, err := history.EncodeDocuments([]history.DocumentRef{{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}}) if err != nil { t.Fatalf("EncodeDocuments: %v", err) } first, err := hist.AppendFull(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "读这份", Document: raw}) if err != nil { t.Fatalf("append 1: %v", err) } second, err := hist.AppendFull(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "再读一次", Document: raw}) if err != nil { t.Fatalf("append 2: %v", err) } if got := refCountOf(t, f, ref.Name); got != 2 { t.Fatalf("两条消息引用后 ref_count = %d, want 2", got) } // 抽取结论回写(同一批名字、只补 status)**不得**再计一次。 merged := `[{"name":"` + ref.Name + `","mime":"` + ref.MIME + `","size":` + strconv.FormatInt(ref.Size, 10) + `,"chars":8,"status":"extracted"}]` if err := hist.UpdateMessageDocument(ctx, first.ID, merged); err != nil { t.Fatalf("UpdateMessageDocument: %v", err) } if got := refCountOf(t, f, ref.Name); got != 2 { t.Fatalf("结论回写后 ref_count = %d, want 2(同一批名字不重复计数)", got) } // 仍被两条消息引用:即便时钟已过保留期 30 天,三样都不许消失。 if _, err := gc.Sweep(ctx); err != nil { t.Fatalf("Sweep(引用中): %v", err) } if o, s, r := f.exists(t, ref.Name); !o || !s || !r { t.Fatalf("被引用的文档被回收了:原件=%v sidecar=%v 行=%v", o, s, r) } // 删掉一条消息 ⇒ 1(仍不删)。 if err := hist.DeleteMessage(ctx, first.ID); err != nil { t.Fatalf("delete 1: %v", err) } if got := refCountOf(t, f, ref.Name); got != 1 { t.Fatalf("删一条后 ref_count = %d, want 1", got) } if _, err := gc.Sweep(ctx); err != nil { t.Fatalf("Sweep(还剩一条引用): %v", err) } if o, s, r := f.exists(t, ref.Name); !o || !s || !r { t.Fatalf("还剩一条引用时被回收了:原件=%v sidecar=%v 行=%v", o, s, r) } // 删掉最后一条 ⇒ 0,且此时(时钟已越过保留期)才可回收:三样一起消失。 if err := hist.DeleteMessage(ctx, second.ID); err != nil { t.Fatalf("delete 2: %v", err) } if got := refCountOf(t, f, ref.Name); got != 0 { t.Fatalf("删净后 ref_count = %d, want 0", got) } stats, err := gc.Sweep(ctx) if err != nil { t.Fatalf("Sweep(无引用 + 超期): %v", err) } if stats.Deleted != 1 { t.Fatalf("stats = %+v, want 删除 1", stats) } if o, s, r := f.exists(t, ref.Name); o || s || r { t.Fatalf("无引用 + 超期后仍留着:原件=%v sidecar=%v 行=%v,want 三样都消失", o, s, r) } }