- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
320 lines
12 KiB
Go
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)
|
|
}
|
|
}
|