Files
Cyrene_v2/internal/runtime/history/refs_test.go
T
AskaEth 3ba31b58ff
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): 引用计数 + 文档/侧车 GC(主人 2.2 裁决;S7)
- 引用计数真实维护(documents 表唯一写入面):增减点只有四处 —— history 的 insert / UpdateMessageDocument / DeleteMessage / DeleteSession,且**全在同一事务**内(计数写失败 ⇒ 消息写入整体回滚,有钉住用例);UpdateMessageDocument 按新旧名字**集合差**增减(三阶段回写是常态,重算会重复计数 ⇒ 文件永远回收不掉)。

- GC:候选 = ref_count = 0;判据 = last_referenced_at(NULL 回落 created_at)早于 now − 保留期;动作 = 删 sidecar + 原件 + 元数据行(删行带 AND ref_count = 0 原子再确认);路径双检;汇总日志(含实占字节);单件失败 WARN 并继续;删除顺序 = 先文件后行(失败则行留着下轮重试,不留孤儿)。

- 组合根:启动期清扫一次(不阻塞启动)+ ticker 常驻(可停,close 顺序在关库之前);env DOCUMENT_GC_INTERVAL 默认 1h / DOCUMENT_RETENTION 默认 168h,**任一 ≤0 = 关闭 GC**(RETENTION=0 不解释成立即回收,关闭时记 WARN);.env.example 已同步(AGENTS §10)。

- 主会话独立验收:隔离 worktree(S7 的 18 文件,排除 F2 在途的 mcp/vision)build/vet 绿、全树 59 包 0 FAIL、GC/引用用例 20 PASS、双平台交叉编译绿;**变异亲自复现**(候选查询去掉 ref_count = 0 ⇒ TestGCDoesNotDeleteReferencedDocument 红:candidates = 1, want 0)。

- 未做(任务书允许的「只做后端」):面板手动「清理未引用附件」入口 —— 后端能力已就绪(documents.NewGC + GC.Sweep),接成端点属小切片(含 admin 路由 + 审计 document.gc + 契约登记)。

- 已知窄窗口(已在 gc.go 文件头登记):一轮 GC 与一次落库几乎同时发生时可能删掉刚被引用的文件(行不删、记 WARN);坏 document 列只记 WARN 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
2026-10-04 21:01:30 +08:00

320 lines
12 KiB
Go

package history
// 本文件钉住**文档引用计数**在消息写路径上的接线(design/24 §3.2 的配额与清理,
// 切片 S7)。用替身计数面记录「谁在什么时候增减了哪些名字」,因此断言的是
// **写路径的行为**而不是某段 SQL:
//
// - 落库 / 删除 / 回写三处各自该增减多少;
// - 抽取结论回写(同批名字、只补 status)**一次都不增减**(重复计数会让文件
// 永远回收不掉,是本切片最容易踩的坑);
// - 幂等键命中冲突(那一行不是本次写的)不计数;
// - 计数写失败时消息写入**整体回滚**(原子性:绝不留「消息在、计数没加」的窗口)。
import (
"context"
"errors"
"fmt"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// recordingRefs 是 DocumentRefCounter 的记录替身。
type recordingRefs struct {
retained []string
released []string
// failOn 非空时,名字里出现它就让该次调用失败(模拟元数据表写不进去)。
failOn string
}
func (r *recordingRefs) Retain(_ context.Context, _ SQLExecer, names []string) error {
if r.failOn != "" {
for _, name := range names {
if name == r.failOn {
return fmt.Errorf("refs: retain %q refused", name)
}
}
}
r.retained = append(r.retained, names...)
return nil
}
func (r *recordingRefs) Release(_ context.Context, _ SQLExecer, names []string) error {
if r.failOn != "" {
for _, name := range names {
if name == r.failOn {
return fmt.Errorf("refs: release %q refused", name)
}
}
}
r.released = append(r.released, names...)
return nil
}
// newRefStore 打开一个隔离内存库并接上引用计数替身。
func newRefStore(t *testing.T, refs DocumentRefCounter) *Store {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := NewStore(ctx, conn, testLogger(), WithDocumentRefs(refs))
if err != nil {
t.Fatalf("new store: %v", err)
}
return st
}
// mustEncode 编码一组引用,失败即终止用例。
func mustEncode(t *testing.T, names ...string) string {
t.Helper()
refs := make([]DocumentRef, 0, len(names))
for _, name := range names {
refs = append(refs, DocumentRef{Name: name, MIME: "text/plain", Size: 10})
}
raw, err := EncodeDocuments(refs)
if err != nil {
t.Fatalf("encode: %v", err)
}
return raw
}
// TestAppendRetainsDocumentRefs 覆盖落库侧:一条带两个附件的消息给两个名字各记
// 一次引用;没有附件的消息一次都不记。
func TestAppendRetainsDocumentRefs(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
raw := mustEncode(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.md")
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "看看", Document: raw}); err != nil {
t.Fatalf("append with documents: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "好的"}); err != nil {
t.Fatalf("append without documents: %v", err)
}
if got := refs.retained; len(got) != 2 ||
got[0] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt" || got[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.md" {
t.Fatalf("retained = %v, want 两个附件名各一次且顺序与列一致", got)
}
if len(refs.released) != 0 {
t.Fatalf("released = %v, want 空", refs.released)
}
}
// TestDeleteMessageReleasesDocumentRefs 覆盖删除侧:删掉一条消息只撤销它自己
// 携带的那份引用。
func TestDeleteMessageReleasesDocumentRefs(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
raw := mustEncode(t, "cccccccccccccccccccccccccccccccc.csv")
msg, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "看", Document: raw})
if err != nil {
t.Fatalf("append: %v", err)
}
if err := st.DeleteMessage(ctx, msg.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if len(refs.released) != 1 || refs.released[0] != "cccccccccccccccccccccccccccccccc.csv" {
t.Fatalf("released = %v, want 恰好撤销那条引用", refs.released)
}
// 未知 id 不该产生任何增减。
if err := st.DeleteMessage(ctx, "does-not-exist"); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("delete unknown = %v, want ErrMessageNotFound", err)
}
if len(refs.released) != 1 {
t.Fatalf("released = %v, want 仍只有一条(未知 id 不增减)", refs.released)
}
}
// TestUpdateMessageDocumentCountsOnlyNameDelta 覆盖回写侧的两半:
// - 同一批名字只补抽取结论 ⇒ **一次都不增减**(重复计数会让文件永远回收不掉);
// - 真正换掉名字集合 ⇒ 只按差增减。
func TestUpdateMessageDocumentCountsOnlyNameDelta(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
first := "11111111111111111111111111111111.txt"
second := "22222222222222222222222222222222.md"
msg, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "读", Document: mustEncode(t, first)})
if err != nil {
t.Fatalf("append: %v", err)
}
// 抽取结论回写:名字不变,只补 status/chars。
withStatus := `[{"name":"` + first + `","mime":"text/plain","size":10,"chars":800,"status":"extracted"}]`
if err := st.UpdateMessageDocument(ctx, msg.ID, withStatus); err != nil {
t.Fatalf("update status: %v", err)
}
if len(refs.retained) != 1 || len(refs.released) != 0 {
t.Fatalf("retained/released = %v/%v, want 1/0(只补结论不该重复计数)", refs.retained, refs.released)
}
// 追加第二个附件:只有新增的那个被 retain。
two := `[{"name":"` + first + `","mime":"text/plain","size":10},` +
`{"name":"` + second + `","mime":"text/markdown","size":20}]`
if err := st.UpdateMessageDocument(ctx, msg.ID, two); err != nil {
t.Fatalf("update add: %v", err)
}
if len(refs.retained) != 2 || refs.retained[1] != second || len(refs.released) != 0 {
t.Fatalf("retained/released = %v/%v, want 只在末尾多一次第二个附件", refs.retained, refs.released)
}
// 去掉第一个附件:只有被去掉的那个被 release。
onlySecond := `[{"name":"` + second + `","mime":"text/markdown","size":20}]`
if err := st.UpdateMessageDocument(ctx, msg.ID, onlySecond); err != nil {
t.Fatalf("update remove: %v", err)
}
if len(refs.released) != 1 || refs.released[0] != first {
t.Fatalf("released = %v, want 只撤销被去掉的第一个附件", refs.released)
}
// 清空列(没有附件):撤销剩下那一条。
if err := st.UpdateMessageDocument(ctx, msg.ID, ""); err != nil {
t.Fatalf("update clear: %v", err)
}
if len(refs.released) != 2 || refs.released[1] != second {
t.Fatalf("released = %v, want 第二个附件也被撤销", refs.released)
}
}
// TestDeleteSessionReleasesAllDocumentRefs 覆盖会话级删除:会话里每一条带附件的
// 消息各自撤销一次(不带附件的消息不产生任何调用)。
func TestDeleteSessionReleasesAllDocumentRefs(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
session, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub session: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "主会话无附件"}); err != nil {
t.Fatalf("append main: %v", err)
}
one := "33333333333333333333333333333333.json"
two := "44444444444444444444444444444444.yaml"
for i, raw := range []string{mustEncode(t, one), mustEncode(t, two)} {
if err := st.Append(ctx, Message{SessionID: session, Role: RoleUser, Content: fmt.Sprintf("第%d条", i), Document: raw}); err != nil {
t.Fatalf("append sub %d: %v", i, err)
}
}
if err := st.DeleteSession(ctx, session); err != nil {
t.Fatalf("delete session: %v", err)
}
if !sameNames(refs.released, one, two) {
t.Fatalf("released = %v, want 子会话两条消息各撤销一次(主会话那条不算,顺序不限)", refs.released)
}
}
// sameNames 报告 got 与 want 是否为同一个多重集合(顺序不限)—— 删除路径的
// 语句次序由 SQLite 的索引扫描决定,与语义无关,因此断言必须是顺序无关的。
func sameNames(got []string, want ...string) bool {
if len(got) != len(want) {
return false
}
counts := map[string]int{}
for _, name := range got {
counts[name]++
}
for _, name := range want {
counts[name]--
}
for _, n := range counts {
if n != 0 {
return false
}
}
return true
}
// TestAppendDuplicateClientMsgIDDoesNotRetain 覆盖幂等冲突侧:ON CONFLICT DO
// NOTHING 命中时那一行不是本次写的,因此**不计数** —— 否则同一个 clientMsgId
// 重发一次就会让计数虚高。
func TestAppendDuplicateClientMsgIDDoesNotRetain(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
raw := mustEncode(t, "55555555555555555555555555555555.log")
msg := Message{SessionID: MainSessionID, Role: RoleUser, Content: "重发", Document: raw, ClientMsgID: "dup-key"}
if _, err := st.AppendFull(ctx, msg); err != nil {
t.Fatalf("first append: %v", err)
}
again := msg
again.ID = ""
if _, err := st.AppendFull(ctx, again); err != nil {
t.Fatalf("second append: %v", err)
}
if len(refs.retained) != 1 {
t.Fatalf("retained = %v, want 只记一次(幂等冲突不计数)", refs.retained)
}
}
// TestAppendRollsBackWhenRefCountFails 覆盖原子性:引用计数写失败 ⇒ 消息行也不落库
// (绝不留「消息在、计数没加」的窗口 —— 那会让 GC 误删仍被引用的文件)。
func TestAppendRollsBackWhenRefCountFails(t *testing.T) {
t.Parallel()
refs := &recordingRefs{failOn: "66666666666666666666666666666666.md"}
st := newRefStore(t, refs)
ctx := context.Background()
raw := mustEncode(t, refs.failOn)
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "写不进去", Document: raw}); err == nil {
t.Fatal("Append = nil, want 引用计数失败的 error")
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 0 {
t.Fatalf("messages = %d, want 0(计数失败必须整体回滚)", len(msgs))
}
}
// TestBadDocumentColumnDoesNotBlockWrites 覆盖坏列的容忍口径:列解不出名字时
// 消息照常落库(消息行才是用户的事实),只留一条 WARN;删掉它也不 panic。
func TestBadDocumentColumnDoesNotBlockWrites(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "坏列", Document: "{not json"})
if err != nil {
t.Fatalf("append bad column: %v", err)
}
if len(refs.retained) != 0 {
t.Fatalf("retained = %v, want 空(名字解不出来就无从增减)", refs.retained)
}
if err := st.DeleteMessage(ctx, msg.ID); err != nil {
t.Fatalf("delete bad column: %v", err)
}
if len(refs.released) != 0 {
t.Fatalf("released = %v, want 空", refs.released)
}
}
// TestWithoutDocumentRefsIsUnchanged 覆盖未接线的口径:不注入计数面时消息读写
// 与切片 S7 之前逐字相同(不 panic、不报错)。
func TestWithoutDocumentRefsIsUnchanged(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
raw := mustEncode(t, "77777777777777777777777777777777.txt")
msg, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "无计数面", Document: raw})
if err != nil {
t.Fatalf("append: %v", err)
}
if err := st.UpdateMessageDocument(ctx, msg.ID, raw); err != nil {
t.Fatalf("update: %v", err)
}
if err := st.DeleteMessage(ctx, msg.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}