Files
Cyrene_v2/internal/runtime/memory/tidy_schedule_test.go
T
AskaEth 454ddb0b70
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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/ 待登记
2026-10-06 22:39:35 +08:00

307 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}