主人实测: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/ 待登记
307 lines
11 KiB
Go
307 lines
11 KiB
Go
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)
|
||
}
|
||
}
|