review P2 两条。 ① 旧说法没删净(同类第 N 次:改了 A,同一主张还留在 B / C)。这次按「**这句主张的所有 落点**」重扫了一遍我这一片里含 Stop / Start / 重启 / 心跳已停 的注释,改掉两处与实现 正面打架的: - applyTidyConfig 的注释还写着「调用方(ApplyPanelTuning)在 Stop 期间不能持 runMu」 「前提仍是心跳已停」—— 而同文件的文件头刚写过「调优路径上一个 Start/Stop 都没有」, 同一文件里两段话自相矛盾; - 整理档那条并发哨兵的循环注释还写着「Stop 把 running 置为 false,忘了重启就会静默 停摆」,而该循环里根本没有 Stop/Start(改成「唤醒不是停表」,并把 Stop/Start 那版 明确标成旧版)。 顺带把文件头「所有读点都收进访问器」的说法改准:此外还有一处**同一把锁下**的相等比较 (ApplyPanelTuning 里的 s.cfg == next),它本身不需要访问器。 ② 新哨兵是概率性守卫:固定 40 轮时撤回 DryRun 同步只红 4/8(写方那几十次覆写在微秒级 就跑完,读方还在慢端点里打转)⇒ 文档规定的 -count=1 会漏掉约一半回归。改成**两侧各自 跑满同一个 300ms 墙钟窗口**(写窗口与读窗口必然重叠,不靠快慢概率),读方两个活端点各 起两路;写方每轮换一个值(否则 ApplyPanelTuning 会被「配置没变」的早退挡掉,根本不写)。 实测:撤回 DryRun 同步红 **8/8**、撤回 ManualRun 同步红 **8/8**(各 8 次 -race -count=1); 干净 tip `-race -count=5` 全绿、三条哨兵一起 `-count=3` 全绿;该哨兵耗时 0.35s(原 0.18s)。 顺带把 clipJSON 用例注释里那句改准:40 字节处被切掉的是第 13 个汉字的**第三个字节** (前两个字节被留下),而不是先前写的「第三个字节上」。
533 lines
22 KiB
Go
533 lines
22 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效,
|
||
// 不要求重启后端)的两条判据:
|
||
// ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)——
|
||
// 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳
|
||
// 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。
|
||
// ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒
|
||
// 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。
|
||
|
||
// tuningInt 返回面板调优用的 *int。
|
||
func tuningInt(v int) *int { return &v }
|
||
|
||
// tuningBool 返回面板调优用的 *bool。
|
||
func tuningBool(v bool) *bool { return &v }
|
||
|
||
// TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认,
|
||
// 且**重算而不是叠加**:清掉一个键就回到 base 那一档。
|
||
func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
|
||
t.Parallel()
|
||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
|
||
|
||
// 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。
|
||
panel := runtimeconfig.MemoryTidyTuning{
|
||
IntervalSec: tuningInt(10800),
|
||
QuietIntervalSec: tuningInt(36000),
|
||
AutoApply: tuningBool(false),
|
||
}
|
||
if err := svc.ApplyPanelTuning(base, panel); err != nil {
|
||
t.Fatalf("ApplyPanelTuning: %v", err)
|
||
}
|
||
if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour {
|
||
t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval)
|
||
}
|
||
if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour {
|
||
t.Fatalf("quiet interval = %d / %s, want 36000 / 10h",
|
||
svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval)
|
||
}
|
||
if !svc.cfg.DisableAutoApply {
|
||
t.Fatal("autoApply:false 应当落到 DisableAutoApply")
|
||
}
|
||
if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 {
|
||
t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg)
|
||
}
|
||
// 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。
|
||
if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply {
|
||
t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg)
|
||
}
|
||
|
||
// 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400,
|
||
// 而不是留在上一次的 10800。
|
||
cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)}
|
||
if err := svc.ApplyPanelTuning(base, cleared); err != nil {
|
||
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
|
||
}
|
||
if svc.cfg.IntervalSec != 86400 {
|
||
t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec)
|
||
}
|
||
if svc.cfg.QuietIntervalSec != 86400 {
|
||
t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec)
|
||
}
|
||
if svc.cfg.DisableAutoApply {
|
||
t.Fatal("清掉 autoApply 后应回到代码默认(开)")
|
||
}
|
||
if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 {
|
||
t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
// 未启动:保存只改配置,不碰心跳状态(也**不**该把它启动起来)。
|
||
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
|
||
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
|
||
t.Fatalf("ApplyPanelTuning(idle): %v", err)
|
||
}
|
||
svc.mu.Lock()
|
||
running := svc.running
|
||
svc.mu.Unlock()
|
||
if running {
|
||
t.Fatal("未 Start 时不该因为保存而启动心跳")
|
||
}
|
||
|
||
probe := newTuneWaitProbe()
|
||
svc.wait = probe.wait
|
||
svc.mu.Lock()
|
||
svc.lastRun = at.Add(-30 * time.Minute)
|
||
svc.mu.Unlock()
|
||
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)
|
||
}
|
||
|
||
// 保存:周期 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()
|
||
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("面板保存不该把心跳停掉")
|
||
}
|
||
}
|
||
|
||
// TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变)
|
||
// 时不打扰心跳:那一次保存不该白白停一次心跳。
|
||
func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) {
|
||
t.Parallel()
|
||
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)
|
||
svc.Start(nil)
|
||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||
same := normalizeTidyConfig(base)
|
||
if svc.cfg != same {
|
||
t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same)
|
||
}
|
||
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil {
|
||
t.Fatalf("ApplyPanelTuning(noop): %v", err)
|
||
}
|
||
if got := svc.nextDelay(); got != 8*time.Hour {
|
||
t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got)
|
||
}
|
||
}
|
||
|
||
// TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样
|
||
// **重算**:清掉即回代码默认(中档结构性为空)。
|
||
func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
// 面板关闸 + 1 小时 TTL。
|
||
panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)}
|
||
if err := reviewer.ApplyPanelTuning(panel); err != nil {
|
||
t.Fatalf("ApplyPanelTuning: %v", err)
|
||
}
|
||
if !reviewer.cfg.DisableIngestGate {
|
||
t.Fatal("ingestGate:false 应当当场关掉同步入库闸")
|
||
}
|
||
if reviewer.cfg.TempTTL != time.Hour {
|
||
t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL)
|
||
}
|
||
// 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。
|
||
if reviewer.gateEnabled() {
|
||
t.Fatal("关闸后 gateEnabled 仍为 true")
|
||
}
|
||
// 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。
|
||
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil {
|
||
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
|
||
}
|
||
if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() {
|
||
t.Fatal("清掉 ingestGate 后应当回到默认(开)")
|
||
}
|
||
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
|
||
t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want)
|
||
}
|
||
// 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。
|
||
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil {
|
||
t.Fatalf("ApplyPanelTuning(bad ttl): %v", err)
|
||
}
|
||
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
|
||
t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL)
|
||
}
|
||
// 审查本身的其它旋钮**一个都不动**(它们不是面板键)。
|
||
if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 {
|
||
t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg)
|
||
}
|
||
}
|
||
|
||
// TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储
|
||
// 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效;
|
||
// 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。
|
||
func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
|
||
t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err)
|
||
}
|
||
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
store.AttachIngestReviewer(reviewer)
|
||
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
|
||
t.Fatalf("Store.ApplyIngestPanelTuning: %v", err)
|
||
}
|
||
if !reviewer.cfg.DisableIngestGate {
|
||
t.Fatal("经 Store 的转调没有落到审查服务上")
|
||
}
|
||
}
|
||
|
||
// TestPreInsertReviewer_PanelTuningRaceWithGateReads 是**并发回归哨兵**(切片
|
||
// MEMPANEL / M4 的快修)。
|
||
//
|
||
// 背景:面板调优(`ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段
|
||
// (`DisableIngestGate` / `TempTTL`)。在这条特性之前 `cfg` 是构造期冻结的,读侧
|
||
// 无锁本来安全;有了运行期写入之后,凡是读这两个字段的地方都必须与写方同步 ——
|
||
// 否则就是「写方持锁、读方不持锁」的数据竞争。读侧两处分别是:
|
||
// - `gateEnabled()`(同步入库闸,记忆**写入路径**上调用);
|
||
// - `deferDeadline()`(第五态 `defer` 落 `expires_at`,写入路径与异步审查都会调)。
|
||
//
|
||
// 它**只在 `-race` 下有意义**:不带 `-race` 时它只是一次并发冒烟(两边都跑完即通过),
|
||
// 有没有竞争交给 race detector 判。跑法:
|
||
//
|
||
// go test -race -count=1 -run TestPreInsertReviewer_PanelTuningRaceWithGateReads ./internal/runtime/memory/
|
||
func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) {
|
||
store := newTestStore(t)
|
||
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
const rounds = 200
|
||
var wg sync.WaitGroup
|
||
wg.Add(3)
|
||
// 写方:模拟面板保存(每次改两个面板键)。
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; i < rounds; i++ {
|
||
panel := runtimeconfig.MemoryTidyTuning{
|
||
IngestGate: tuningBool(i%2 == 0),
|
||
TempTtlSec: tuningInt(3600 + i),
|
||
}
|
||
if err := reviewer.ApplyPanelTuning(panel); err != nil {
|
||
t.Errorf("ApplyPanelTuning: %v", err)
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
// 读方一:同步入库闸的开关判定(写入路径)。
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; i < rounds; i++ {
|
||
_ = reviewer.gateEnabled()
|
||
}
|
||
}()
|
||
// 读方二:第五态的到期时刻。
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; i < rounds; i++ {
|
||
_ = reviewer.deferDeadline(time.Now())
|
||
}
|
||
}()
|
||
wg.Wait()
|
||
}
|
||
|
||
// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。
|
||
//
|
||
// 背景(实测踩出来的,不是推演):面板调优会在运行期**整体覆写** `s.cfg`,因此该字段
|
||
// 的**每一个**读点都必须与写方同步。心跳那一路的读点(`nextDelay` / `dueNow`)现在走
|
||
// `scheduleSnapshot()`(同一把锁下同取「cfg + lastRun」)—— 这条哨兵钉的就是它:早先
|
||
// 那版把写入放在 `Start` **之后**,新起的循环立刻读 `s.cfg` 重算下一拍,与写入并排
|
||
// (而且那一拍还可能按**旧**配置算出来),`-race` 会当场报出来。
|
||
//
|
||
// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine
|
||
// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让
|
||
// **真心跳**当读者(连跑多轮,每轮都换一次周期),与生产形态一致。
|
||
//
|
||
// 与它同族的另两条哨兵:审查档(`PreInsertReviewer` 的并发读写)与试算/手动跑那两个
|
||
// **活端点**(`TestTidyService_PanelTuningRaceWithDryRunGuard`)。
|
||
//
|
||
// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法:
|
||
//
|
||
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithLiveHeartbeat ./internal/runtime/memory/
|
||
func TestTidyService_PanelTuningRaceWithLiveHeartbeat(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)
|
||
svc.Start(nil)
|
||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||
|
||
const rounds = 25
|
||
for i := 0; i < rounds; i++ {
|
||
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
|
||
if err := svc.ApplyPanelTuning(base, panel); err != nil {
|
||
t.Fatalf("ApplyPanelTuning(%d): %v", i, err)
|
||
}
|
||
}
|
||
// 每轮重排之后心跳都必须还活着(唤醒**不是**停表:走 Stop/Start 那版才会把
|
||
// running 置为 false,忘了重启就静默停摆 —— 现在这条路径上根本没有 Start/Stop),
|
||
// 且最后一轮的周期真的生效在运行期。
|
||
svc.mu.Lock()
|
||
running := svc.running
|
||
svc.mu.Unlock()
|
||
if !running {
|
||
t.Fatal("连着保存多次之后心跳必须仍在跑")
|
||
}
|
||
if want := 3600 + rounds - 1; svc.cfg.IntervalSec != want {
|
||
t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want)
|
||
}
|
||
}
|
||
|
||
// TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵
|
||
// (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与
|
||
// **试算 / 手动跑两个活端点的开关判定**并发。
|
||
//
|
||
// 为什么单独要这一条:`TidyService.DryRun` 与 `TidyService.ManualRun` 的
|
||
// `if !s.enabled()` 都发生在**取 runMu 之前**,不受 runMu 保护 —— 它们正是
|
||
// `POST /admin/memories/tidy/dry-run`(含 `?async=1` 那条会起后台 goroutine、真机上
|
||
// 跑 90s 级的版本)与 `POST /admin/memories/tidy/run` 这条读点。只把「调度读」收进
|
||
// 持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。
|
||
//
|
||
// 形态是**两个墙钟窗口完全重叠**的连续调用(不是固定轮数)—— 这一点是被实测逼出来的:
|
||
// 早先用「写方 40 轮 / 读方 40 轮」时,写方那 40 次覆写在微秒级就跑完了,读方还在慢
|
||
// 端点里打转,于是「撤回 DryRun 同步」这个变异只红 4/8(`-count=1` 会漏掉约一半的
|
||
// 回归)。改成两侧各自跑满同一个 300ms 窗口后,变异 8/8 必红(实测数字见回报)。
|
||
//
|
||
// 写方每轮都换一个值:`ApplyPanelTuning` 对「配置没变」会早退(那是它的省事路径),
|
||
// 一直写同一个值就根本不会触发 `s.cfg` 的覆写,这条哨兵也就测不到东西。
|
||
//
|
||
// 它**只在 `-race` 下有意义**(不带 -race 时是一次并发冒烟 + 结果断言)。跑法:
|
||
//
|
||
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithDryRunGuard ./internal/runtime/memory/
|
||
func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) {
|
||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
|
||
|
||
const raceWindow = 300 * time.Millisecond
|
||
stop := make(chan struct{})
|
||
var wg sync.WaitGroup
|
||
|
||
// 写方:连续覆写 s.cfg(每次换一个周期)。
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; ; i++ {
|
||
select {
|
||
case <-stop:
|
||
return
|
||
default:
|
||
}
|
||
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
|
||
if err := svc.ApplyPanelTuning(base, panel); err != nil {
|
||
t.Errorf("ApplyPanelTuning: %v", err)
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
// 读方:两个活端点各起两路(撞上写窗口的机会更多;两路都不改语义)。
|
||
reader := func(name string, call func() error) {
|
||
defer wg.Done()
|
||
for {
|
||
select {
|
||
case <-stop:
|
||
return
|
||
default:
|
||
}
|
||
if err := call(); err != nil {
|
||
t.Errorf("%s: %v", name, err)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
dryRun := func() error {
|
||
if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
manualRun := func() error {
|
||
if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
wg.Add(4)
|
||
go reader("DryRun", dryRun)
|
||
go reader("DryRun", dryRun)
|
||
go reader("ManualRun", manualRun)
|
||
go reader("ManualRun", manualRun)
|
||
|
||
time.Sleep(raceWindow)
|
||
close(stop)
|
||
wg.Wait()
|
||
}
|