diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index c25ba08..8b3eedd 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -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() diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index 22d78df..6058bf4 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -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() } diff --git a/internal/services/gateway/admin_memory_tuning_guard_test.go b/internal/services/gateway/admin_memory_tuning_guard_test.go index 24aa56a..785ace7 100644 --- a/internal/services/gateway/admin_memory_tuning_guard_test.go +++ b/internal/services/gateway/admin_memory_tuning_guard_test.go @@ -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()