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 个汉字的**第三个字节**
(前两个字节被留下),而不是先前写的「第三个字节上」。
This commit is contained in:
AskaEth committed 2026-10-13 22:50:38 +08:00
1 parent 4df8d6f53a
commit f6c47b0ae1
3 files changed
+70 -33

No files matched your search

@@ -48,10 +48,11 @@ import (
// Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。
//
// 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被
// 运行期整体覆写,它的**每一个读点**都必须与写方同步,因此所有读点都收进了持锁访问器
// (`enabled()` / `cfgSnapshot()` / `scheduleSnapshot()`:调度读 `nextDelay` /
// `dueNow`、两个活端点的开关判定 `DryRun` / `ManualRun`、以及 `Enabled()` 与 `Start`
// 的启动日志)。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、
// 运行期整体覆写,它的**每一个**读点都必须与写方同步。做法是读写都在 `s.mu` 下:读点
// 收进三个访问器 `enabled()`(开关)/ `cfgSnapshot()`(整份)/ `scheduleSnapshot()`
// (整份 + lastRun),覆盖调度读 `nextDelay` / `dueNow`、两个活端点的开关判定
// `DryRun` / `ManualRun`、`Enabled()` 与 `Start` 的启动日志;此外 `ApplyPanelTuning`
// 里还有一处**同一把锁下**的相等比较(`s.cfg == next`),它本身不需要访问器。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、
// 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
@@ -197,8 +198,9 @@ func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log
//
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
//
// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它),因为调用方
// (ApplyPanelTuning)在 `Stop` 期间**不能**持它。前提仍是「心跳已停」:调用方负责。
// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它)—— 调优路径**不等
// 任何后台件停**:`s.cfg` 的读点全部持 `s.mu`(见文件头),`tidier.cfg` 的读点全部持
// `runMu`,因此这把锁足以让写入与每一轮的读不交手,不需要(也不再)停心跳。
func (s *TidyService) applyTidyConfig(cfg TidyConfig) {
s.runMu.Lock()
defer s.runMu.Unlock()
@@ -432,7 +432,8 @@ func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) {
t.Fatalf("ApplyPanelTuning(%d): %v", i, err)
}
}
// 每轮重排之后心跳都必须还活着(`Stop` 把 running 置为 false,忘了重启就会静默停摆),
// 每轮重排之后心跳都必须还活着(唤醒**不是**停表:走 Stop/Start 那版才会把
// running 置为 false,忘了重启就静默停摆 —— 现在这条路径上根本没有 Start/Stop),
// 且最后一轮的周期真的生效在运行期。
svc.mu.Lock()
running := svc.running
@@ -447,12 +448,21 @@ func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) {
// TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵
// (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与
// **试算端点的开关判定**并发。
// **试算 / 手动跑两个活端点的开关判定**并发。
//
// 为什么单独要这一条:`TidyService.DryRun` 的 `if !s.cfg.Enabled` 发生在**取 runMu
// 之前**,因此它不受 runMu 保护 —— 而 `POST /admin/memories/tidy/dry-run`(含
// `?async=1` 那条会起后台 goroutine、真机上跑 90s 级的版本)正是这条读点的活端点。
// 只把「调度读」收进持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。
// 为什么单独要这一条:`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 时是一次并发冒烟 + 结果断言)。跑法:
//
@@ -461,13 +471,20 @@ 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 rounds = 40
const raceWindow = 300 * time.Millisecond
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
// 写方:模拟面板保存(每次换一个周期,一定走整体覆写 s.cfg)。
// 写方:连续覆写 s.cfg(每次换一个周期)。
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
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)
@@ -475,24 +492,41 @@ func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) {
}
}
}()
// 读方:两个**活端点**交替 —— 试算(POST …/tidy/dry-run)与手动真跑
// (POST …/tidy/run)。两者的开关判定都在取 runMu **之前**,因此都在这个字段上
// 与写方交手;只覆盖其中一个就等于漏了另一个。
go func() {
// 读方:两个活端点各起两路(撞上写窗口的机会更多;两路都不改语义)。
reader := func(name string, call func() error) {
defer wg.Done()
for i := 0; i < rounds; i++ {
if i%2 == 0 {
if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
t.Errorf("DryRun: %v", err)
return
}
continue
for {
select {
case <-stop:
return
default:
}
if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
t.Errorf("ManualRun: %v", err)
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()
}
@@ -271,9 +271,10 @@ func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
// 变成 U+FFFD 替换符),等于把「你填了什么」显示成乱码。
//
// 取值刻意让截断点**落在多字节字符中间**:JSON 值自带引号(1 字节),再让首个字符是
// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,第 40 字节正好切在第 13 个
// 汉字的**第三个字节**上。若只堆汉字(引号 1 + 3n 字节),40 会恰好落在 rune 边界上,
// 那样这条用例会**假绿**(实测过:按字节截断也照样通过)。
// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,于是 s[:40] 覆盖「引号 + a +
// 12 个完整汉字」(2 + 36 = 38 字节)再切掉第 13 个汉字的**第三个字节**(它的前两个
// 字节被留下)⇒ 那半个汉字成了非法 UTF-8。若只堆汉字(引号 1 + 3n 字节),40 会恰好
// 落在 rune 边界上,那样这条用例会**假绿**(实测过:按字节截断也照样通过)。
func TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe(t *testing.T) {
t.Parallel()