feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。 根因两条(都在这片里修掉): ① **以前根本没有执行面** —— 只有只读试算,候选永远 pending ② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完 - 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并** (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计 (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道) - 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run), 「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死** - 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时) - 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column) - 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false) ⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行) ⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
This commit is contained in:
1 parent
57d0e8c543
commit
454ddb0b70
18 files changed
+3181
-71
No files matched your search
@@ -0,0 +1,306 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理**调度修复**(切片 TIDY-1)的三条判据:
|
||||
//
|
||||
// ① **重启不饿死**:进程在安静窗内启动、且上次 run 已超过 interval ⇒ 启动即补跑
|
||||
// (旧实现只看进程内 lastRun + 24h ticker,后端两天重启十几次 ⇒ 永远不跑);
|
||||
// ② **跨过安静窗起点会跑**:启动在窗口外、无历史 ⇒ 等到窗口起点那一轮自己跑;
|
||||
// ③ **不重复跑**:同一安静窗内已经跑过(lastRun 在窗口起点之后)⇒ 重启不再跑,
|
||||
// 否则「修好饿死」会变成「每次重启都全库扫一遍」。
|
||||
//
|
||||
// 用例全部用**注入时钟 + 假等待**:不真等、不靠真实时间。假等待把假时钟推进到
|
||||
// 下一次唤醒时刻,因此「跨过窗口起点」是可确定性验证的。
|
||||
|
||||
// schedClock 是可推进的假时钟。
|
||||
type schedClock struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *schedClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.at
|
||||
}
|
||||
|
||||
func (c *schedClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.at = c.at.Add(d)
|
||||
}
|
||||
|
||||
// tidyWaitProbe 是假等待 + 「已被调用」信号的组合:假等待把假时钟推进到下一次
|
||||
// 唤醒时刻,同时把每次调用记进 signals —— 于是用例可以**确定性地**等到
|
||||
// 「循环已经走到某个阶段」再断言 / 再 Stop,不必真等也不必 sleep。
|
||||
//
|
||||
// 信号序列与循环的对应关系:第 1 次 wait 之前一定已经做完启动补跑判定;
|
||||
// 第 N+1 次 wait 之前一定已经做完第 N 次唤醒后的那一轮。
|
||||
type tidyWaitProbe struct {
|
||||
clock *schedClock
|
||||
budget int
|
||||
calls int
|
||||
signals chan int
|
||||
}
|
||||
|
||||
func newTidyWaitProbe(clock *schedClock, budget int) *tidyWaitProbe {
|
||||
return &tidyWaitProbe{clock: clock, budget: budget, signals: make(chan int, 16)}
|
||||
}
|
||||
|
||||
func (p *tidyWaitProbe) wait(ctx context.Context, d time.Duration) bool {
|
||||
p.calls++
|
||||
select {
|
||||
case p.signals <- p.calls:
|
||||
default:
|
||||
}
|
||||
if p.calls > p.budget {
|
||||
return false
|
||||
}
|
||||
p.clock.advance(d)
|
||||
return true
|
||||
}
|
||||
|
||||
// awaitCalls 等到假等待已被调用 n 次(每次至多等 5 秒;超时即 FAIL)。
|
||||
func (p *tidyWaitProbe) awaitCalls(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case <-p.signals:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("等待第 %d 次心跳唤醒超时(循环没走到预期阶段)", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedTidyRun 直接落一条运行留痕(模拟「上次 run 的持久事实」)。
|
||||
func seedTidyRun(t *testing.T, store *Store, startedAt time.Time) {
|
||||
t.Helper()
|
||||
if err := store.persistTidyRun(context.Background(), TidyRun{
|
||||
ID: mustNewTidyID(),
|
||||
StartedAt: startedAt.UTC(),
|
||||
FinishedAt: startedAt.UTC(),
|
||||
RuleVersion: TidyRuleVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("写运行留痕: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tidyRunTimes 返回运行留痕的开始时刻(最新在前)。
|
||||
func tidyRunTimes(t *testing.T, store *Store) []string {
|
||||
t.Helper()
|
||||
runs, err := store.ListTidyRuns(context.Background(), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
out = append(out, r.StartedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestTidyServiceCatchUpAfterRestartDoesNotStarve 是判据①:上次 run 已超过
|
||||
// interval(这里用 25h),进程在安静窗内重启 ⇒ **启动即补跑**,不等 ticker。
|
||||
//
|
||||
// 假等待立刻返回 false(模拟「还没到下一个窗口,进程又被重启了」)—— 这正是
|
||||
// 后端两天重启十几次的真实情形:只看 ticker 的实现会永远不跑。
|
||||
func TestTidyServiceCatchUpAfterRestartDoesNotStarve(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
// 上次 run:两天前 23:00(安静窗内),现在 23:30 ⇒ 距上次 48.5h ≥ 24h。
|
||||
last := time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local)
|
||||
seedTidyRun(t, store, last)
|
||||
now := time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local)
|
||||
|
||||
clock := &schedClock{at: now}
|
||||
probe := newTidyWaitProbe(clock, 0) // 一次都不等到下一次窗口
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1) // 循环已走完启动补跑判定并进入等待
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
times := tidyRunTimes(t, store)
|
||||
if len(times) != 2 {
|
||||
t.Fatalf("运行留痕 = %v, want 2(历史 1 + 启动补跑 1)", times)
|
||||
}
|
||||
if times[0] != now.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("补跑时刻 = %q, want 启动时刻 %q", times[0], now.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceCatchUpOnFirstStartInsideQuietHours 是判据①的「无历史」分支:
|
||||
// 全新库在安静窗内启动 ⇒ 立刻跑一轮(旧口径会一直等到下一个 23:00)。
|
||||
func TestTidyServiceCatchUpOnFirstStartInsideQuietHours(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
now := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
|
||||
clock := &schedClock{at: now}
|
||||
probe := newTidyWaitProbe(clock, 0)
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1)
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
times := tidyRunTimes(t, store)
|
||||
if len(times) != 1 || times[0] != now.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("运行留痕 = %v, want 恰为启动时刻 %q", times, now.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceRunsWhenQuietWindowStarts 是判据②:启动在窗口外(22:00)且无
|
||||
// 历史 ⇒ 先不跑;假等待把时钟推进到窗口起点(23:00)⇒ 心跳跑一轮。
|
||||
func TestTidyServiceRunsWhenQuietWindowStarts(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
clock := &schedClock{at: time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)}
|
||||
probe := newTidyWaitProbe(clock, 1) // 第一次唤醒(= 窗口起点)后放行,第二次退出
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 2) // 第 2 次唤醒之前必然已跑完窗口起点那一轮
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 10, 7, 23, 0, 0, 0, time.Local)
|
||||
times := tidyRunTimes(t, store)
|
||||
if len(times) != 1 || times[0] != want.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("运行留痕 = %v, want 恰为窗口起点 %q", times, want.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDoesNotRerunInsideSameQuietWindow 是判据③(防过度补跑):
|
||||
// 同一安静窗内已经跑过 ⇒ 重启不再跑。窗口是跨午夜的(23:00–07:00),因此
|
||||
// 用例特意用「今天 00:30」这种「窗口起点在昨天 23:00」的时刻。
|
||||
func TestTidyServiceDoesNotRerunInsideSameQuietWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
last := time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local) // 窗口起点 23:00 之后
|
||||
seedTidyRun(t, store, last)
|
||||
clock := &schedClock{at: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local)} // 同一窗口内
|
||||
probe := newTidyWaitProbe(clock, 0)
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1)
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if times := tidyRunTimes(t, store); len(times) != 1 {
|
||||
t.Fatalf("运行留痕 = %v, want 只保留历史那一条(同一窗口不重复跑)", times)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDueNowJudgements 把 dueNow 的三条判据逐条钉死(纯函数式断言,
|
||||
// 不涉及 goroutine)。
|
||||
func TestTidyServiceDueNowJudgements(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil)
|
||||
|
||||
// 时钟由用例直接改写 svc.now(不经过 Start)。
|
||||
at := func(tm time.Time) { svc.now = func() time.Time { return tm } }
|
||||
setLast := func(tm time.Time) { svc.lastRun = tm }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
last time.Time
|
||||
due bool
|
||||
}{
|
||||
{
|
||||
name: "无历史且在窗口外 ⇒ 不补跑",
|
||||
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
|
||||
last: time.Time{}, due: false,
|
||||
},
|
||||
{
|
||||
name: "无历史且在窗口内 ⇒ 补跑",
|
||||
now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local),
|
||||
last: time.Time{}, due: true,
|
||||
},
|
||||
{
|
||||
name: "距上次已超 interval ⇒ 补跑(窗口外也跑)",
|
||||
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 6, 14, 0, 0, 0, time.Local), due: true,
|
||||
},
|
||||
{
|
||||
name: "窗口内且本窗口未跑 ⇒ 补跑",
|
||||
now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 7, 12, 0, 0, 0, time.Local), due: true,
|
||||
},
|
||||
{
|
||||
name: "窗口内且本窗口已跑 ⇒ 不补跑(跨午夜也算同一窗口)",
|
||||
now: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local), due: false,
|
||||
},
|
||||
{
|
||||
name: "窗口外且未到周期 ⇒ 不补跑",
|
||||
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 7, 2, 0, 0, 0, time.Local), due: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
at(tc.now)
|
||||
setLast(tc.last)
|
||||
due, reason := svc.dueNow()
|
||||
if due != tc.due {
|
||||
t.Fatalf("dueNow = %v(%s), want %v", due, reason, tc.due)
|
||||
}
|
||||
if due && reason == "" {
|
||||
t.Fatal("补跑必须带原因标签(日志/排障要用)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceManualRunRespectsDisabled 钉住手动真跑入口的口径:关闭时如实
|
||||
// 返回 ErrTidyDisabled(不退化成空结果),开启时与 RunOnce 同一条路径。
|
||||
func TestTidyServiceManualRunRespectsDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
off := tidyTestCfg(0.80)
|
||||
off.Enabled = false
|
||||
svcOff := newTidyServiceForTest(t, store, off, nil, nil, nil)
|
||||
if _, err := svcOff.ManualRun(ctx); err != ErrTidyDisabled {
|
||||
t.Fatalf("关闭时 ManualRun err = %v, want ErrTidyDisabled", err)
|
||||
}
|
||||
|
||||
on := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil)
|
||||
stats, err := on.ManualRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ManualRun: %v", err)
|
||||
}
|
||||
if stats.RunID == "" {
|
||||
t.Fatal("真跑必须留下运行 id(候选按它归属)")
|
||||
}
|
||||
runs := tidyRunTimes(t, store)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("运行留痕 = %v, want 1", runs)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user