fix(memory): 按字段扫清 s.cfg 的其余无锁读(P1)+ 回显文案按 rune 截断
review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁 访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**: - TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与 ?async=1 那条会起后台 goroutine 的版本(真机 90s 级); - TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run; - (原本也漏的)Enabled() 与 Start 的启动日志块。 按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器 内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处; s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。 新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。 修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回 ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原 (diff 0 差异)。 顺手两条(塔指定): - 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成 唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。 - 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经 JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的 clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀 错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
This commit is contained in:
1 parent
97c0c1b94a
commit
4df8d6f53a
6 files changed
+167
-29
No files matched your search
@@ -2,6 +2,7 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -401,15 +402,18 @@ func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) {
|
||||
|
||||
// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。
|
||||
//
|
||||
// 背景(实测踩出来的,不是推演):`s.cfg` 的**调度读**(`nextDelay` / `dueNow`)
|
||||
// 不持锁,因此「面板调优写 cfg」必须落在一个**没有任何 goroutine 读它**的窗口里。
|
||||
// 曾经的顺序 `Stop → Start → 写` 在这里会被 -race 抓住:新起的循环立刻读 `s.cfg`
|
||||
// 重算下一拍,与写入并排(而且那一拍还可能按**旧**配置算出来)。正确顺序是
|
||||
// `Stop → 写 → Start`。
|
||||
// 背景(实测踩出来的,不是推演):面板调优会在运行期**整体覆写** `s.cfg`,因此该字段
|
||||
// 的**每一个**读点都必须与写方同步。心跳那一路的读点(`nextDelay` / `dueNow`)现在走
|
||||
// `scheduleSnapshot()`(同一把锁下同取「cfg + lastRun」)—— 这条哨兵钉的就是它:早先
|
||||
// 那版把写入放在 `Start` **之后**,新起的循环立刻读 `s.cfg` 重算下一拍,与写入并排
|
||||
// (而且那一拍还可能按**旧**配置算出来),`-race` 会当场报出来。
|
||||
//
|
||||
// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine
|
||||
// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让
|
||||
// **真心跳**当读者(连跑多轮,每轮都要停一次、起一次),与生产形态一致。
|
||||
// **真心跳**当读者(连跑多轮,每轮都换一次周期),与生产形态一致。
|
||||
//
|
||||
// 与它同族的另两条哨兵:审查档(`PreInsertReviewer` 的并发读写)与试算/手动跑那两个
|
||||
// **活端点**(`TestTidyService_PanelTuningRaceWithDryRunGuard`)。
|
||||
//
|
||||
// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法:
|
||||
//
|
||||
@@ -440,3 +444,55 @@ func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) {
|
||||
t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵
|
||||
// (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与
|
||||
// **试算端点的开关判定**并发。
|
||||
//
|
||||
// 为什么单独要这一条:`TidyService.DryRun` 的 `if !s.cfg.Enabled` 发生在**取 runMu
|
||||
// 之前**,因此它不受 runMu 保护 —— 而 `POST /admin/memories/tidy/dry-run`(含
|
||||
// `?async=1` 那条会起后台 goroutine、真机上跑 90s 级的版本)正是这条读点的活端点。
|
||||
// 只把「调度读」收进持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。
|
||||
//
|
||||
// 它**只在 `-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 rounds = 40
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
// 写方:模拟面板保存(每次换一个周期,一定走整体覆写 s.cfg)。
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
|
||||
if err := svc.ApplyPanelTuning(base, panel); err != nil {
|
||||
t.Errorf("ApplyPanelTuning: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 读方:两个**活端点**交替 —— 试算(POST …/tidy/dry-run)与手动真跑
|
||||
// (POST …/tidy/run)。两者的开关判定都在取 runMu **之前**,因此都在这个字段上
|
||||
// 与写方交手;只覆盖其中一个就等于漏了另一个。
|
||||
go func() {
|
||||
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
|
||||
}
|
||||
if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
|
||||
t.Errorf("ManualRun: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in new issue
Block a user