- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
86 lines
2.6 KiB
Go
86 lines
2.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
"lokistack/models"
|
|
)
|
|
|
|
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
|
|
// N 个 goroutine 并发修改 db 并各自 RequestSave(经防抖 channel 序列化),
|
|
// 随后同步刷盘,直接读取磁盘文件校验写入数量与 ID 唯一性——
|
|
// 证明 channel 序列化 + 原子重命名写入不会发生"后写覆盖先写"的丢更新。
|
|
func TestConcurrentSaveNoDataLoss(t *testing.T) {
|
|
const N = 50
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < N; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
db := GetDB()
|
|
// NextID 内部自行加锁;切勿在持有 db.Lock() 时调用(mu 不可重入,会自死锁)
|
|
id := db.NextID("hosts")
|
|
db.Lock()
|
|
db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "concurrent-host"})
|
|
db.Unlock()
|
|
db.RequestSave()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// 同步刷盘一次(绕过 500ms 防抖),确保最新内存状态落盘
|
|
if err := GetDB().RequestSaveSync(); err != nil {
|
|
t.Fatalf("RequestSaveSync: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(dbPath())
|
|
if err != nil {
|
|
t.Fatalf("read db file: %v", err)
|
|
}
|
|
var disk JsonDB
|
|
if err := json.Unmarshal(data, &disk); err != nil {
|
|
t.Fatalf("unmarshal db file: %v", err)
|
|
}
|
|
if len(disk.Hosts) != N {
|
|
t.Fatalf("DATA LOSS: disk has %d hosts, want %d (concurrent saves lost writes)", len(disk.Hosts), N)
|
|
}
|
|
|
|
// ID 唯一性:NextID 在 mutex 保护下递增,不应出现重复
|
|
seen := make(map[uint]bool, N)
|
|
for _, h := range disk.Hosts {
|
|
if seen[h.ID] {
|
|
t.Fatalf("duplicate host ID %d (NextID race)", h.ID)
|
|
}
|
|
seen[h.ID] = true
|
|
}
|
|
}
|
|
|
|
// TestRequestSaveDebounce 校验防抖行为:短时间内多次 RequestSave 不会撑爆 channel。
|
|
func TestRequestSaveDebounce(t *testing.T) {
|
|
db := GetDB()
|
|
startLen := len(db.Hosts)
|
|
// 连续触发 100 次防抖保存(channel 容量 10,防抖应合并而非阻塞)
|
|
for i := 0; i < 100; i++ {
|
|
id := db.NextID("hosts") // 自行加锁,勿在 db.Lock() 内调用
|
|
db.Lock()
|
|
db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "debounce"})
|
|
db.Unlock()
|
|
db.RequestSave()
|
|
}
|
|
if err := db.RequestSaveSync(); err != nil {
|
|
t.Fatalf("RequestSaveSync after debounce: %v", err)
|
|
}
|
|
data, _ := os.ReadFile(dbPath())
|
|
var disk JsonDB
|
|
if err := json.Unmarshal(data, &disk); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
// 100 次追加 + 之前测试的 N 个 host,必须全部落盘
|
|
if len(disk.Hosts) != startLen+100 {
|
|
t.Fatalf("debounce lost writes: disk has %d hosts, want %d", len(disk.Hosts), startLen+100)
|
|
}
|
|
}
|