feat(memory): 调优重排改为唤醒通道,不再 Stop/Start(消灭与到期清扫的耦合)
按塔的裁定把「重排心跳」从生命周期操作改成唤醒通道,调优路径上一个 Start/Stop 都没有了。 - tidy_service.go:TidyService 新增 wake(cap 1 + 非阻塞发)与 wakeMu/wakeCancel; loop 的等待段抽成 waitForTick,三态判定(睡满 / 被调优唤醒 / 停);被唤醒只 重算下一拍、**绝不**跑轮。调度读(nextDelay / dueNow)改走 scheduleSnapshot, 在同一把锁下同取「cfg + lastRun」,于是运行期写 cfg 不必再把后台件停下来。 Start / Stop / wg / running 与 Enabled 早退语义**一个字节没动**。 - tidy_service_tuning.go:ApplyPanelTuning 变成「持 runMu 写 cfg → 发一次唤醒」。 唤醒 = 一次性标记(保证不丢:心跳不在等时标记留在缓冲里)**加上**取消当前那次 等待(保证立刻生效,否则 24h→3h 要等原来那一觉睡完)。 两条冲突就此消失,各配一条可复现断言: - **面板保存不再触发到期清扫、也不重置清扫 ticker**:新用例在保存前后各造一行已 到期的记忆,保存之后那行必须**还在**、清扫审计计数必须不变;该用例自带活对照 (启动清扫会删掉第一行),因此「行还在」不是量具失灵。若保存走 Stop/Start,清扫 的「启动即扫一轮」会立刻把它删掉。 - **调优路径上不再存在任何 Start 调用**(结构上消灭交叉 Start/Stop):包内生产代码 已 grep 不到任何 `.Start(` / `.Stop(` 调用,Start 与 Stop 各只剩组合根那一处调用点 (`cmd/cyrene/main.go` / `cmd/cyrene/wire_services.go`),M3 注释里那条「Start/Stop 各只调用一次」的不变式因此重新成立;交叉场景由带真心跳的调优用例在 `-race` 下跑过 (不再 panic)。 热生效没退化:原来那条「23h30m → 30m」的断言按新机制重写 —— 用一个「听得懂 ctx 取消」的假等待看两觉的时长(保存前 23h30m、保存当场的第二觉 30m),并补上「唤醒不 跑轮(lastRun 不前移)」「唤醒后 running 仍为 true」。 变异自证(都跑过再还原,`diff` 逐字一致): - 去掉 `notifyReschedule` 调用 ⇒「被唤醒后的第二觉」等不到 ⇒ 用例红; - 只留标记、去掉「取消当前等待」那一半 ⇒ 同一条红(两半都必需)。 M3 的守卫 `expire_loop_test.go` **一行未动**:它的假 wait 只认 ctx,父 ctx 取消时 返回 false,在新机制下语义不变(无人唤醒时照样把心跳停住)。
This commit is contained in:
1 parent
a87fbeb494
commit
97c0c1b94a
3 files changed
+328
-82
No files matched your search
@@ -1,6 +1,8 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -76,16 +78,61 @@ func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳
|
||||
// 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。
|
||||
func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) {
|
||||
t.Parallel()
|
||||
// tuneWaitProbe 是一个「听得懂 ctx 取消」的假等待:记录每次被要求的时长,并在
|
||||
// ctx 结束时立刻返回 false —— 与生产的 `waitTidyTimer` 同语义。因此它既能被
|
||||
// **调优唤醒**打断(唤醒 = 取消这一次等待的子 ctx),也能被 Stop 停住。
|
||||
type tuneWaitProbe struct {
|
||||
mu sync.Mutex
|
||||
delays []time.Duration
|
||||
calls chan time.Duration
|
||||
}
|
||||
|
||||
func newTuneWaitProbe() *tuneWaitProbe {
|
||||
return &tuneWaitProbe{calls: make(chan time.Duration, 32)}
|
||||
}
|
||||
|
||||
func (p *tuneWaitProbe) wait(ctx context.Context, d time.Duration) bool {
|
||||
p.mu.Lock()
|
||||
p.delays = append(p.delays, d)
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case p.calls <- d:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(30 * time.Second):
|
||||
// 兜底:用例失败时才可能走到这里(正常路径都由取消叫醒)。
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// awaitCall 等下一次「等待」被调用并返回它要求的时长(超时即 FAIL)。
|
||||
func (p *tuneWaitProbe) awaitCall(t *testing.T, step string) time.Duration {
|
||||
t.Helper()
|
||||
select {
|
||||
case d := <-p.calls:
|
||||
return d
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("等心跳走到「%s」超时(循环没走到预期阶段)", step)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence 钉住**热生效的新机制**
|
||||
// (唤醒,而不是 Stop/Start):
|
||||
//
|
||||
// ① 保存前那一觉是按**旧**周期算的(15:00 起、上次 14:30、周期 24h ⇒ 等 23h30m);
|
||||
// ② 保存**当场**把那一觉作废并按新周期重排(下一觉 = 30m),不必等旧周期睡完;
|
||||
// ③ 唤醒**不跑轮**(`lastRun` 不前移 —— 到没到点仍只由 dueNow 判);
|
||||
// ④ 心跳**没有**被停过:`running` 仍为 true,等待由**同一个**循环继续(不是重启)。
|
||||
func TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence(t *testing.T) {
|
||||
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
|
||||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
|
||||
|
||||
// 未启动:保存只改配置,不碰心跳状态(用一个不含 intervalSec 的补丁,免得
|
||||
// 影响下面「旧周期」的断言)。
|
||||
// 未启动:保存只改配置,不碰心跳状态(也**不**该把它启动起来)。
|
||||
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
|
||||
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(idle): %v", err)
|
||||
@@ -97,29 +144,114 @@ func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) {
|
||||
t.Fatal("未 Start 时不该因为保存而启动心跳")
|
||||
}
|
||||
|
||||
// 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。
|
||||
// 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。
|
||||
svc.Start(nil)
|
||||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||||
probe := newTuneWaitProbe()
|
||||
svc.wait = probe.wait
|
||||
svc.mu.Lock()
|
||||
svc.lastRun = at.Add(-30 * time.Minute)
|
||||
svc.mu.Unlock()
|
||||
if got := svc.nextDelay(); got != 23*time.Hour+30*time.Minute {
|
||||
t.Fatalf("启动后 nextDelay = %s, want 23h30m(旧周期)", got)
|
||||
svc.Start(nil)
|
||||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||||
|
||||
if got := probe.awaitCall(t, "第一觉"); got != 23*time.Hour+30*time.Minute {
|
||||
t.Fatalf("第一觉 = %s, want 23h30m(按旧周期)", got)
|
||||
}
|
||||
// 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有
|
||||
// 30 分钟),而不是继续睡原来那 23.5 小时。
|
||||
|
||||
// 保存:周期 24h → 1h。这一觉必须**当场**作废并重排到 30 分钟。
|
||||
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(running): %v", err)
|
||||
}
|
||||
if got := probe.awaitCall(t, "被唤醒后的第二觉"); got != 30*time.Minute {
|
||||
t.Fatalf("第二觉 = %s, want 30m(仍按旧周期说明没被唤醒)", got)
|
||||
}
|
||||
|
||||
svc.mu.Lock()
|
||||
running = svc.running
|
||||
last, runningAfter := svc.lastRun, svc.running
|
||||
svc.mu.Unlock()
|
||||
if !runningAfter {
|
||||
t.Fatal("唤醒后心跳必须仍在跑(唤醒不是停表)")
|
||||
}
|
||||
if !last.Equal(at.Add(-30 * time.Minute)) {
|
||||
t.Fatalf("唤醒跑了轮:lastRun = %v, want 14:30(唤醒只重排,不跑轮)", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows 钉住「重排不碰生命周期」:
|
||||
// 面板保存**不再**顺带触发一次到期清扫,也**不再**重启清扫的 ticker。
|
||||
//
|
||||
// 判据用行为而不是实现:保存前后各造一行已到期的记忆,保存之后它必须**还在**。
|
||||
// 若保存走了 Stop/Start(早先那版就是),`expirySweepLoop` 的「启动即扫一轮」会
|
||||
// 立刻把它删掉 —— 这正是塔要证伪的那条副作用。
|
||||
func TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := tidyTestTime(t)
|
||||
store := newTestStore(t)
|
||||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
audits []string
|
||||
)
|
||||
svc := newTidyServiceForTest(t, store, base, nil, fixedTidyClock(now),
|
||||
func(_, what, result string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
audits = append(audits, what+" "+result)
|
||||
})
|
||||
// 假等待:把心跳停在 ctx 上(本轮只关心清扫那一路,心跳不搅局)。
|
||||
svc.wait = func(ctx context.Context, _ time.Duration) bool {
|
||||
<-ctx.Done()
|
||||
return false
|
||||
}
|
||||
expireAudits := func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n := 0
|
||||
for _, a := range audits {
|
||||
if strings.HasPrefix(a, expireAuditVerb+" ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ① 先证明清扫确实在跑:启动清扫会把这一行删掉。
|
||||
first := addItem(t, store, MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Content: "启动清扫应当删掉它", ExpiresAt: now.Add(-time.Minute),
|
||||
})
|
||||
svc.Start(ctx)
|
||||
t.Cleanup(func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = svc.Stop(stopCtx)
|
||||
})
|
||||
waitUntilRowGone(t, store, first.ID)
|
||||
afterStart := expireAudits()
|
||||
|
||||
// ② 再造一行已到期的,这次没有任何「启动清扫」来删它。
|
||||
pending := addItem(t, store, MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Content: "面板保存不该把它删掉", ExpiresAt: now.Add(-time.Minute),
|
||||
})
|
||||
|
||||
// ③ 面板保存(值真的变了,因此一定会走重排)。
|
||||
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning: %v", err)
|
||||
}
|
||||
// 给「万一真被触发了」留出显形窗口:启动清扫只需一次 SQLite 往返(微秒级)。
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if !memoryRowExists(t, store, pending.ID) {
|
||||
t.Fatal("面板保存触发了到期清扫(那行被删了)—— 重排不该碰生命周期")
|
||||
}
|
||||
if got := expireAudits(); got != afterStart {
|
||||
t.Fatalf("面板保存后多出了清扫审计:%d ⇒ %d", afterStart, got)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
running := svc.running
|
||||
svc.mu.Unlock()
|
||||
if !running {
|
||||
t.Fatal("重排后心跳必须仍在跑(否则定时整理会悄悄停摆)")
|
||||
}
|
||||
if got := svc.nextDelay(); got != 30*time.Minute {
|
||||
t.Fatalf("重排后的 nextDelay = %s, want 30m(仍按旧周期说明没重排)", got)
|
||||
t.Fatal("面板保存不该把心跳停掉")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user