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) } }