review p1:Set 的固定 tmp 名(path+".tmp")+ 落盘发生在 mu.Unlock() 之后 ⇒ 并发 Set 撞同一临时文件把 JSON 写坏(load 退回默认「甲」、主人刚说的「少说点」 白说且无提示),或 rename 完成顺序与新快照先后不一致而丢更新。 修法(两条都取,各司其职): - 落盘挪进锁内(Set 全程持 s.mu):并发 Set 完全串行 ⇒ 快照与 rename 顺序一致, 不可能丢更新。落盘是一个小 JSON,持锁 I/O 代价可忽略;读侧读内存、不读文件。 - 每次用 os.CreateTemp 新建临时文件(仓库同款先例 internal/user/save.go), 不再撞固定 tmp 名;CreateTemp 以 0600 打开并再 chmod 一次,任一步失败都清掉 tmp。 - 旧 save(快照) 换成 saveLocked()(调用方须持锁,直接 marshal s.cfg)。 测试:新增并发用例 TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(24 轮 × 24 写者), 断言 ① 文件始终是合法 JSON、② 每一次成功的 Set 都能在最终文件读到(不丢更新)。 修前红(原实现在同一用例上):5/5 轮复现,两种失败模式都抓到 —— "并发落盘把配置写坏了(文件不是合法 JSON):invalid character '\"' after top-level value" 与 "丢更新 —— 键 qq-obv11|g-N-M 不在最终文件里";修后连续 3 次绿,-race 下亦 ok。 Bridge.SetOwnerLanePolicy 无需改动:它在 ownerLaneMu 下设内存后调注入的 store, 串行化由 store 侧保证。
135 lines
4.9 KiB
Go
135 lines
4.9 KiB
Go
package runtimeconfig
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」):
|
|
// 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。
|
|
func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
|
s := NewOwnerLaneStore(path, nil)
|
|
|
|
got := s.PolicyFor("qq-obv11", "672432767")
|
|
if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 {
|
|
t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的
|
|
// store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。
|
|
func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
|
s := NewOwnerLaneStore(path, nil)
|
|
want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120}
|
|
if err := s.Set("qq-obv11", "672432767", want); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("owner_lane.json 未落盘:%v", err)
|
|
}
|
|
|
|
reloaded := NewOwnerLaneStore(path, nil)
|
|
if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want {
|
|
t.Fatalf("重载后该群策略 = %+v, want %+v", got, want)
|
|
}
|
|
// 未被覆盖的群仍走全局 default(零值)。
|
|
if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() {
|
|
t.Fatalf("未覆盖群策略 = %+v, want 默认档", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档:
|
|
// groupID 为空即写默认,所有未单独覆盖的群都跟着变。
|
|
func TestOwnerLaneStoreGlobalDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil)
|
|
global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60}
|
|
if err := s.Set("", "", global); err != nil {
|
|
t.Fatalf("Set default: %v", err)
|
|
}
|
|
if got := s.PolicyFor("telegram", "-20"); got != global {
|
|
t.Fatalf("全局默认生效值 = %+v, want %+v", got, global)
|
|
}
|
|
}
|
|
|
|
// TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」:
|
|
// 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。
|
|
func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config", "owner_lane.json")
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
s := NewOwnerLaneStore(path, nil)
|
|
if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() {
|
|
t.Fatalf("坏配置下策略 = %+v, want 默认档", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1):
|
|
// 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个
|
|
// tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示),
|
|
// 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。
|
|
//
|
|
// 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言:
|
|
// ① 每一轮结束后文件都是**合法 JSON**;
|
|
// ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。
|
|
func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
|
s := NewOwnerLaneStore(path, nil)
|
|
|
|
const (
|
|
rounds = 24
|
|
writers = 24
|
|
)
|
|
for round := 0; round < rounds; round++ {
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < writers; i++ {
|
|
key := fmt.Sprintf("g-%d-%d", round, i)
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
// 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错,
|
|
// 但那正是本用例要靠**文件内容**抓住的失败。
|
|
_ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i})
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err)
|
|
}
|
|
var f OwnerLaneFile
|
|
if err := json.Unmarshal(raw, &f); err != nil {
|
|
t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw)
|
|
}
|
|
for i := 0; i < writers; i++ {
|
|
key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i))
|
|
if _, ok := f.Groups[key]; !ok {
|
|
t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|