Files
Cyrene_v2/internal/runtime/memory/tidy_service_tuning_test.go
AskaEth f6c47b0ae1 fix(memory): 清掉两处与实现相反的旧说法 + 并发哨兵改成窗口重叠(变异 8/8 红)
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 个汉字的**第三个字节**
(前两个字节被留下),而不是先前写的「第三个字节上」。
2026-10-13 22:50:38 +08:00

533 lines
22 KiB
Go
Raw Permalink 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"
"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()
}