前端:重构 Web UI 代码结构
- 添加 Import Map 支持 Lit 和本地模块的简洁导入 - 创建统一的 API 管理模块 (common/api.js) - 重命名 styles/ 为 common/ 目录 - 修复分页时列选择被重置的问题 - 将 app.js 重命名为 main.js - 所有导入路径使用 ~ 别名映射
This commit is contained in:
929
compaction.go
929
compaction.go
File diff suppressed because it is too large
Load Diff
276
compaction_continuity_test.go
Normal file
276
compaction_continuity_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package srdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPickL0MergeContinuity 测试 L0 合并任务的连续性
|
||||
func TestPickL0MergeContinuity(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manifestDir := tmpDir
|
||||
|
||||
versionSet, err := NewVersionSet(manifestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer versionSet.Close()
|
||||
|
||||
picker := NewPicker()
|
||||
|
||||
// 创建混合大小的文件:小-大-小-小
|
||||
// 这是触发 bug 的场景
|
||||
edit := NewVersionEdit()
|
||||
|
||||
// 文件1: 29MB (小文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 1,
|
||||
Level: 0,
|
||||
FileSize: 29 * 1024 * 1024,
|
||||
MinKey: 1,
|
||||
MaxKey: 100,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
// 文件2: 36MB (大文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 2,
|
||||
Level: 0,
|
||||
FileSize: 36 * 1024 * 1024,
|
||||
MinKey: 101,
|
||||
MaxKey: 200,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
// 文件3: 8MB (小文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 3,
|
||||
Level: 0,
|
||||
FileSize: 8 * 1024 * 1024,
|
||||
MinKey: 201,
|
||||
MaxKey: 300,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
// 文件4: 15MB (小文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 4,
|
||||
Level: 0,
|
||||
FileSize: 15 * 1024 * 1024,
|
||||
MinKey: 301,
|
||||
MaxKey: 400,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
edit.SetNextFileNumber(5)
|
||||
err = versionSet.LogAndApply(edit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
version := versionSet.GetCurrent()
|
||||
|
||||
// 测试 Stage 0: L0 合并任务
|
||||
t.Log("=== 测试 Stage 0: L0 合并 ===")
|
||||
tasks := picker.pickL0MergeTasks(version)
|
||||
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected L0 merge tasks")
|
||||
}
|
||||
|
||||
t.Logf("找到 %d 个合并任务", len(tasks))
|
||||
|
||||
// 验证任务:应该只有1个任务,包含文件3和文件4
|
||||
// 文件1是单个小文件,不合并(len > 1 才合并)
|
||||
// 文件2是大文件,跳过
|
||||
// 文件3+文件4是连续的2个小文件,应该合并
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("Expected 1 task, got %d", len(tasks))
|
||||
for i, task := range tasks {
|
||||
t.Logf("Task %d: %d files", i+1, len(task.InputFiles))
|
||||
for _, f := range task.InputFiles {
|
||||
t.Logf(" - File %d", f.FileNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
task1 := tasks[0]
|
||||
if len(task1.InputFiles) != 2 {
|
||||
t.Errorf("Task 1: expected 2 files, got %d", len(task1.InputFiles))
|
||||
}
|
||||
if task1.InputFiles[0].FileNumber != 3 || task1.InputFiles[1].FileNumber != 4 {
|
||||
t.Errorf("Task 1: expected files 3,4, got %d,%d",
|
||||
task1.InputFiles[0].FileNumber, task1.InputFiles[1].FileNumber)
|
||||
}
|
||||
t.Logf("✓ 合并任务: 文件3+文件4 (连续的2个小文件)")
|
||||
t.Logf("✓ 文件1 (单个小文件) 不合并,留给升级阶段")
|
||||
|
||||
// 验证 seq 范围连续性
|
||||
// 任务1: seq 201-400 (文件3+文件4)
|
||||
// 文件1(seq 1-100, 单个小文件)留给升级阶段
|
||||
// 文件2(seq 101-200, 大文件)留给 Stage 1
|
||||
if task1.InputFiles[0].MinKey != 201 || task1.InputFiles[1].MaxKey != 400 {
|
||||
t.Errorf("Task 1 seq range incorrect: [%d, %d]",
|
||||
task1.InputFiles[0].MinKey, task1.InputFiles[1].MaxKey)
|
||||
}
|
||||
t.Logf("✓ Seq 范围正确:任务1 [201-400]")
|
||||
|
||||
// 测试 Stage 1: L0 升级任务
|
||||
t.Log("=== 测试 Stage 1: L0 升级 ===")
|
||||
upgradeTasks := picker.pickL0UpgradeTasks(version)
|
||||
|
||||
if len(upgradeTasks) == 0 {
|
||||
t.Fatal("Expected L0 upgrade tasks")
|
||||
}
|
||||
|
||||
// 应该有1个任务:以文件2(大文件)为中心,搭配周围的小文件
|
||||
// 文件2向左收集文件1,向右收集文件3和文件4
|
||||
// 总共:文件1 (29MB) + 文件2 (36MB) + 文件3 (8MB) + 文件4 (15MB) = 88MB
|
||||
if len(upgradeTasks) != 1 {
|
||||
t.Errorf("Expected 1 upgrade task, got %d", len(upgradeTasks))
|
||||
}
|
||||
upgradeTask := upgradeTasks[0]
|
||||
|
||||
// 应该包含所有4个文件
|
||||
if len(upgradeTask.InputFiles) != 4 {
|
||||
t.Errorf("Upgrade task: expected 4 files, got %d", len(upgradeTask.InputFiles))
|
||||
for i, f := range upgradeTask.InputFiles {
|
||||
t.Logf(" File %d: %d", i+1, f.FileNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证文件顺序:1, 2, 3, 4
|
||||
expectedFiles := []int64{1, 2, 3, 4}
|
||||
for i, expected := range expectedFiles {
|
||||
if upgradeTask.InputFiles[i].FileNumber != expected {
|
||||
t.Errorf("Upgrade task file %d: expected %d, got %d",
|
||||
i, expected, upgradeTask.InputFiles[i].FileNumber)
|
||||
}
|
||||
}
|
||||
|
||||
if upgradeTask.OutputLevel != 1 {
|
||||
t.Errorf("Upgrade task: expected OutputLevel 1, got %d", upgradeTask.OutputLevel)
|
||||
}
|
||||
t.Logf("✓ 升级任务: 文件1+文件2+文件3+文件4 (以大文件为中心,搭配周围小文件) → L1")
|
||||
|
||||
t.Log("=== 连续性测试通过 ===")
|
||||
}
|
||||
|
||||
// TestPickL0UpgradeContinuity 测试 L0 升级任务的连续性
|
||||
func TestPickL0UpgradeContinuity(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manifestDir := tmpDir
|
||||
|
||||
versionSet, err := NewVersionSet(manifestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer versionSet.Close()
|
||||
|
||||
picker := NewPicker()
|
||||
|
||||
// 创建混合大小的文件:大-小-大-大
|
||||
edit := NewVersionEdit()
|
||||
|
||||
// 文件1: 40MB (大文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 1,
|
||||
Level: 0,
|
||||
FileSize: 40 * 1024 * 1024,
|
||||
MinKey: 1,
|
||||
MaxKey: 100,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
// 文件2: 20MB (小文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 2,
|
||||
Level: 0,
|
||||
FileSize: 20 * 1024 * 1024,
|
||||
MinKey: 101,
|
||||
MaxKey: 200,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
// 文件3: 50MB (大文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 3,
|
||||
Level: 0,
|
||||
FileSize: 50 * 1024 * 1024,
|
||||
MinKey: 201,
|
||||
MaxKey: 300,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
// 文件4: 45MB (大文件)
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: 4,
|
||||
Level: 0,
|
||||
FileSize: 45 * 1024 * 1024,
|
||||
MinKey: 301,
|
||||
MaxKey: 400,
|
||||
RowCount: 100,
|
||||
})
|
||||
|
||||
edit.SetNextFileNumber(5)
|
||||
err = versionSet.LogAndApply(edit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
version := versionSet.GetCurrent()
|
||||
|
||||
// 测试 L0 升级任务
|
||||
t.Log("=== 测试 L0 升级任务连续性 ===")
|
||||
tasks := picker.pickL0UpgradeTasks(version)
|
||||
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("Expected L0 upgrade tasks")
|
||||
}
|
||||
|
||||
t.Logf("找到 %d 个升级任务", len(tasks))
|
||||
|
||||
// 验证任务1:应该包含所有4个文件(以大文件为锚点,搭配周围文件)
|
||||
// 文件1(大文件)作为锚点 → 向左无文件 → 向右收集文件2(小)+文件3(大)+文件4(大)
|
||||
// 总大小:40+20+50+45 = 155MB < 256MB,符合 L1 限制
|
||||
task1 := tasks[0]
|
||||
expectedFileCount := 4
|
||||
if len(task1.InputFiles) != expectedFileCount {
|
||||
t.Errorf("Task 1: expected %d files, got %d", expectedFileCount, len(task1.InputFiles))
|
||||
for i, f := range task1.InputFiles {
|
||||
t.Logf(" File %d: %d", i+1, f.FileNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证文件顺序:1, 2, 3, 4
|
||||
expectedFiles := []int64{1, 2, 3, 4}
|
||||
for i, expected := range expectedFiles {
|
||||
if i >= len(task1.InputFiles) {
|
||||
break
|
||||
}
|
||||
if task1.InputFiles[i].FileNumber != expected {
|
||||
t.Errorf("Task 1 file %d: expected %d, got %d",
|
||||
i, expected, task1.InputFiles[i].FileNumber)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ Task 1: 文件1+文件2+文件3+文件4 (以大文件为锚点,搭配周围文件,总155MB < 256MB)")
|
||||
|
||||
// 只应该有1个任务(所有文件都被收集了)
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("Expected 1 task (all files collected), got %d", len(tasks))
|
||||
for i, task := range tasks {
|
||||
t.Logf("Task %d: %d files", i+1, len(task.InputFiles))
|
||||
for _, f := range task.InputFiles {
|
||||
t.Logf(" - File %d", f.FileNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证所有任务的 OutputLevel 都是 1
|
||||
for i, task := range tasks {
|
||||
if task.OutputLevel != 1 {
|
||||
t.Errorf("Task %d: expected OutputLevel 1, got %d", i+1, task.OutputLevel)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 所有任务都升级到 L1")
|
||||
|
||||
t.Log("=== 升级任务连续性测试通过 ===")
|
||||
}
|
||||
220
compaction_stage_test.go
Normal file
220
compaction_stage_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package srdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPickerStageRotation 测试 Picker 的阶段轮换机制
|
||||
func TestPickerStageRotation(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir := t.TempDir()
|
||||
manifestDir := tmpDir
|
||||
|
||||
// 创建 VersionSet
|
||||
versionSet, err := NewVersionSet(manifestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer versionSet.Close()
|
||||
|
||||
// 创建 Picker
|
||||
picker := NewPicker()
|
||||
|
||||
// 初始阶段应该是 L0
|
||||
if stage := picker.GetCurrentStage(); stage != 0 {
|
||||
t.Errorf("Initial stage should be 0 (L0), got %d", stage)
|
||||
}
|
||||
|
||||
// 添加 L0 文件(触发 L0 compaction)
|
||||
edit := NewVersionEdit()
|
||||
for i := 0; i < 10; i++ {
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: int64(i + 1),
|
||||
Level: 0,
|
||||
FileSize: 10 * 1024 * 1024, // 10MB each
|
||||
MinKey: int64(i * 100),
|
||||
MaxKey: int64((i+1)*100 - 1),
|
||||
RowCount: 100,
|
||||
})
|
||||
}
|
||||
edit.SetNextFileNumber(11)
|
||||
err = versionSet.LogAndApply(edit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
version := versionSet.GetCurrent()
|
||||
|
||||
// 第1次调用:应该返回 L0 任务,然后推进到 L1
|
||||
t.Log("=== 第1次调用 PickCompaction ===")
|
||||
tasks1 := picker.PickCompaction(version)
|
||||
if len(tasks1) == 0 {
|
||||
t.Error("Expected L0 tasks on first call")
|
||||
}
|
||||
for _, task := range tasks1 {
|
||||
if task.Level != 0 {
|
||||
t.Errorf("Expected L0 task, got L%d", task.Level)
|
||||
}
|
||||
}
|
||||
if stage := picker.GetCurrentStage(); stage != 1 {
|
||||
t.Errorf("After L0 tasks, stage should be 1 (L1), got %d", stage)
|
||||
}
|
||||
t.Logf("✓ Returned %d L0 tasks, stage advanced to L1", len(tasks1))
|
||||
|
||||
// 第2次调用:应该尝试 Stage 1 (L0-upgrade,没有大文件)
|
||||
t.Log("=== 第2次调用 PickCompaction ===")
|
||||
tasks2 := picker.PickCompaction(version)
|
||||
if len(tasks2) == 0 {
|
||||
t.Log("✓ Stage 1 (L0-upgrade) has no tasks")
|
||||
}
|
||||
// 此时 stage 应该已经循环(尝试了 Stage 1→2→3→0...)
|
||||
if stage := picker.GetCurrentStage(); stage >= 0 {
|
||||
t.Logf("After trying, current stage is %d", stage)
|
||||
}
|
||||
|
||||
// 现在添加 L1 文件
|
||||
edit2 := NewVersionEdit()
|
||||
for i := 0; i < 20; i++ {
|
||||
edit2.AddFile(&FileMetadata{
|
||||
FileNumber: int64(100 + i + 1),
|
||||
Level: 1,
|
||||
FileSize: 20 * 1024 * 1024, // 20MB each
|
||||
MinKey: int64(i * 200),
|
||||
MaxKey: int64((i+1)*200 - 1),
|
||||
RowCount: 200,
|
||||
})
|
||||
}
|
||||
edit2.SetNextFileNumber(121)
|
||||
err = versionSet.LogAndApply(edit2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
version2 := versionSet.GetCurrent()
|
||||
|
||||
// 现在可能需要多次调用才能到达 Stage 2 (L1-upgrade)
|
||||
// 因为要经过 Stage 1 (L0-upgrade) 和 Stage 0 (L0-merge)
|
||||
t.Log("=== 多次调用 PickCompaction 直到找到 L1 任务 ===")
|
||||
var tasks3 []*CompactionTask
|
||||
for i := 0; i < 8; i++ { // 最多尝试两轮(4个阶段×2)
|
||||
tasks3 = picker.PickCompaction(version2)
|
||||
if len(tasks3) > 0 && tasks3[0].Level == 1 {
|
||||
t.Logf("✓ Found %d L1 tasks after %d attempts", len(tasks3), i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(tasks3) == 0 || tasks3[0].Level != 1 {
|
||||
t.Error("Expected to find L1 tasks within 8 attempts")
|
||||
}
|
||||
|
||||
t.Log("=== Stage rotation test passed ===")
|
||||
}
|
||||
|
||||
// TestPickerStageWithMultipleLevels 测试多层级同时有任务时的阶段轮换
|
||||
func TestPickerStageWithMultipleLevels(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
manifestDir := tmpDir
|
||||
|
||||
versionSet, err := NewVersionSet(manifestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer versionSet.Close()
|
||||
|
||||
picker := NewPicker()
|
||||
|
||||
// 同时添加 L0、L1、L2 文件
|
||||
edit := NewVersionEdit()
|
||||
|
||||
// L0 小文件: 5 files × 10MB = 50MB (应该触发 Stage 0: L0-merge)
|
||||
for i := 0; i < 5; i++ {
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: int64(i + 1),
|
||||
Level: 0,
|
||||
FileSize: 10 * 1024 * 1024,
|
||||
MinKey: int64(i * 100),
|
||||
MaxKey: int64((i+1)*100 - 1),
|
||||
RowCount: 100,
|
||||
})
|
||||
}
|
||||
|
||||
// L0 大文件: 5 files × 40MB = 200MB (应该触发 Stage 1: L0-upgrade)
|
||||
for i := 0; i < 5; i++ {
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: int64(10 + i + 1),
|
||||
Level: 0,
|
||||
FileSize: 40 * 1024 * 1024,
|
||||
MinKey: int64((i+5) * 100),
|
||||
MaxKey: int64((i+6)*100 - 1),
|
||||
RowCount: 100,
|
||||
})
|
||||
}
|
||||
|
||||
// L1: 20 files × 20MB = 400MB (应该触发 Stage 2: L1-upgrade,256MB阈值)
|
||||
for i := 0; i < 20; i++ {
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: int64(100 + i + 1),
|
||||
Level: 1,
|
||||
FileSize: 20 * 1024 * 1024,
|
||||
MinKey: int64(i * 200),
|
||||
MaxKey: int64((i+1)*200 - 1),
|
||||
RowCount: 200,
|
||||
})
|
||||
}
|
||||
|
||||
// L2: 10 files × 150MB = 1500MB (应该触发 Stage 3: L2-upgrade,1GB阈值)
|
||||
for i := 0; i < 10; i++ {
|
||||
edit.AddFile(&FileMetadata{
|
||||
FileNumber: int64(200 + i + 1),
|
||||
Level: 2,
|
||||
FileSize: 150 * 1024 * 1024,
|
||||
MinKey: int64(i * 300),
|
||||
MaxKey: int64((i+1)*300 - 1),
|
||||
RowCount: 300,
|
||||
})
|
||||
}
|
||||
|
||||
edit.SetNextFileNumber(301)
|
||||
err = versionSet.LogAndApply(edit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
version := versionSet.GetCurrent()
|
||||
|
||||
// 验证阶段按顺序执行:Stage 0→1→2→3→0→1→2→3
|
||||
expectedStages := []struct {
|
||||
stage int
|
||||
name string
|
||||
level int
|
||||
}{
|
||||
{0, "L0-merge", 0},
|
||||
{1, "L0-upgrade", 0},
|
||||
{2, "L1-upgrade", 1},
|
||||
{3, "L2-upgrade", 2},
|
||||
{0, "L0-merge", 0},
|
||||
{1, "L0-upgrade", 0},
|
||||
{2, "L1-upgrade", 1},
|
||||
{3, "L2-upgrade", 2},
|
||||
}
|
||||
|
||||
for i, expected := range expectedStages {
|
||||
t.Logf("=== 第%d次调用 PickCompaction (期望 Stage %d: %s) ===", i+1, expected.stage, expected.name)
|
||||
tasks := picker.PickCompaction(version)
|
||||
|
||||
if len(tasks) == 0 {
|
||||
t.Errorf("Call %d: Expected tasks from Stage %d (%s), got no tasks", i+1, expected.stage, expected.name)
|
||||
continue
|
||||
}
|
||||
|
||||
actualLevel := tasks[0].Level
|
||||
if actualLevel != expected.level {
|
||||
t.Errorf("Call %d: Expected L%d tasks, got L%d tasks", i+1, expected.level, actualLevel)
|
||||
} else {
|
||||
t.Logf("✓ Call %d: Got %d tasks from L%d (Stage %d: %s) as expected",
|
||||
i+1, len(tasks), actualLevel, expected.stage, expected.name)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("=== Multi-level stage rotation test passed ===")
|
||||
}
|
||||
@@ -143,12 +143,14 @@ func TestCompactionBasic(t *testing.T) {
|
||||
t.Errorf("Expected L0 compaction, got L%d", task.Level)
|
||||
}
|
||||
|
||||
if task.OutputLevel != 1 {
|
||||
t.Errorf("Expected output to L1, got L%d", task.OutputLevel)
|
||||
// 注意:L0 compaction 任务的 OutputLevel 设为 0(建议层级)
|
||||
// 实际层级由 determineLevel 根据合并后的文件大小决定
|
||||
if task.OutputLevel != 0 {
|
||||
t.Errorf("Expected output to L0 (suggested), got L%d", task.OutputLevel)
|
||||
}
|
||||
|
||||
t.Logf("Found %d compaction tasks", len(tasks))
|
||||
t.Logf("First task: L%d -> L%d, %d files", task.Level, task.OutputLevel, len(task.InputFiles))
|
||||
t.Logf("First task: L%d -> L%d, %d files (determineLevel will decide actual level)", task.Level, task.OutputLevel, len(task.InputFiles))
|
||||
|
||||
// 清理
|
||||
reader1.Close()
|
||||
@@ -193,12 +195,18 @@ func TestPickerLevelScore(t *testing.T) {
|
||||
|
||||
// 计算 L0 的得分
|
||||
score := picker.GetLevelScore(version, 0)
|
||||
t.Logf("L0 score: %.2f (files: %d, limit: %d)", score, version.GetLevelFileCount(0), picker.levelFileLimits[0])
|
||||
|
||||
// L0 有 3 个文件,限制是 4,得分应该是 0.75
|
||||
expectedScore := 3.0 / 4.0
|
||||
// L0 有 3 个文件,每个 1MB,总共 3MB
|
||||
// 下一级(L1)的限制是 256MB
|
||||
// 得分应该是 3MB / 256MB = 0.01171875
|
||||
totalSize := int64(3 * 1024 * 1024) // 3MB
|
||||
expectedScore := float64(totalSize) / float64(level1SizeLimit)
|
||||
|
||||
t.Logf("L0 score: %.4f (files: %d, total: %d bytes, next level limit: %d)",
|
||||
score, version.GetLevelFileCount(0), totalSize, level1SizeLimit)
|
||||
|
||||
if score != expectedScore {
|
||||
t.Errorf("Expected L0 score %.2f, got %.2f", expectedScore, score)
|
||||
t.Errorf("Expected L0 score %.4f, got %.4f", expectedScore, score)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,9 +552,9 @@ func TestCompactionQueryOrder(t *testing.T) {
|
||||
stats := table.GetCompactionManager().GetLevelStats()
|
||||
t.Logf("Compaction 统计:")
|
||||
for _, levelStat := range stats {
|
||||
level := levelStat["level"].(int)
|
||||
fileCount := levelStat["file_count"].(int)
|
||||
totalSize := levelStat["total_size"].(int64)
|
||||
level := levelStat.Level
|
||||
fileCount := levelStat.FileCount
|
||||
totalSize := levelStat.TotalSize
|
||||
if fileCount > 0 {
|
||||
t.Logf(" L%d: %d 个文件, %.2f MB", level, fileCount, float64(totalSize)/(1024*1024))
|
||||
}
|
||||
|
||||
@@ -948,10 +948,10 @@ func TestTableWithCompaction(t *testing.T) {
|
||||
// 获取 Level 统计信息
|
||||
levelStats := table.compactionManager.GetLevelStats()
|
||||
for _, stat := range levelStats {
|
||||
level := stat["level"].(int)
|
||||
fileCount := stat["file_count"].(int)
|
||||
totalSize := stat["total_size"].(int64)
|
||||
score := stat["score"].(float64)
|
||||
level := stat.Level
|
||||
fileCount := stat.FileCount
|
||||
totalSize := stat.TotalSize
|
||||
score := stat.Score
|
||||
|
||||
if fileCount > 0 {
|
||||
t.Logf("L%d: %d files, %d bytes, score: %.2f", level, fileCount, totalSize, score)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// FileMetadata SST 文件元数据
|
||||
type FileMetadata struct {
|
||||
FileNumber int64 // 文件编号
|
||||
Level int // 所在层级 (0-6)
|
||||
Level int // 所在层级 (0-3)
|
||||
FileSize int64 // 文件大小
|
||||
MinKey int64 // 最小 key
|
||||
MaxKey int64 // 最大 key
|
||||
@@ -24,12 +24,12 @@ type FileMetadata struct {
|
||||
}
|
||||
|
||||
const (
|
||||
NumLevels = 7 // L0-L6
|
||||
NumLevels = 4 // L0-L3
|
||||
)
|
||||
|
||||
// Version 数据库的一个版本快照
|
||||
type Version struct {
|
||||
// 分层存储 SST 文件 (L0-L6)
|
||||
// 分层存储 SST 文件 (L0-L3)
|
||||
Levels [NumLevels][]*FileMetadata
|
||||
|
||||
// 下一个文件编号
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
|
||||
<!-- Import Map for Lit and local modules -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js",
|
||||
"lit/": "https://cdn.jsdelivr.net/gh/lit/dist@3/",
|
||||
"~/": "/static/js/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 应用容器 -->
|
||||
@@ -20,6 +31,6 @@
|
||||
<srdb-modal-dialog></srdb-modal-dialog>
|
||||
|
||||
<!-- 加载 Lit 组件 -->
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
174
webui/static/js/common/api.js
Normal file
174
webui/static/js/common/api.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* API 请求管理模块
|
||||
* 统一管理所有后端接口请求
|
||||
*/
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* 通用请求处理函数
|
||||
* @param {string} url - 请求 URL
|
||||
* @param {RequestInit} options - fetch 选项
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function request(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
error.status = response.status;
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API request failed:', url, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表相关 API
|
||||
*/
|
||||
export const tableAPI = {
|
||||
/**
|
||||
* 获取所有表列表
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async list() {
|
||||
return request(`${API_BASE}/tables`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取表的 Schema
|
||||
* @param {string} tableName - 表名
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getSchema(tableName) {
|
||||
return request(`${API_BASE}/tables/${tableName}/schema`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取表数据(分页)
|
||||
* @param {string} tableName - 表名
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.pageSize - 每页大小
|
||||
* @param {string} params.select - 选择的列(逗号分隔)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getData(tableName, { page = 1, pageSize = 20, select = '' } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
});
|
||||
|
||||
if (select) {
|
||||
params.append('select', select);
|
||||
}
|
||||
|
||||
return request(`${API_BASE}/tables/${tableName}/data?${params}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单行数据详情
|
||||
* @param {string} tableName - 表名
|
||||
* @param {number} seq - 序列号
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getRow(tableName, seq) {
|
||||
return request(`${API_BASE}/tables/${tableName}/data/${seq}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取表的 Manifest 信息
|
||||
* @param {string} tableName - 表名
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getManifest(tableName) {
|
||||
return request(`${API_BASE}/tables/${tableName}/manifest`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 插入数据
|
||||
* @param {string} tableName - 表名
|
||||
* @param {Object} data - 数据对象
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async insert(tableName, data) {
|
||||
return request(`${API_BASE}/tables/${tableName}/data`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量插入数据
|
||||
* @param {string} tableName - 表名
|
||||
* @param {Array<Object>} data - 数据数组
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async batchInsert(tableName, data) {
|
||||
return request(`${API_BASE}/tables/${tableName}/data/batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除表
|
||||
* @param {string} tableName - 表名
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async delete(tableName) {
|
||||
return request(`${API_BASE}/tables/${tableName}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取表统计信息
|
||||
* @param {string} tableName - 表名
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getStats(tableName) {
|
||||
return request(`${API_BASE}/tables/${tableName}/stats`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据库相关 API
|
||||
*/
|
||||
export const databaseAPI = {
|
||||
/**
|
||||
* 获取数据库信息
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getInfo() {
|
||||
return request(`${API_BASE}/database/info`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取数据库统计信息
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getStats() {
|
||||
return request(`${API_BASE}/database/stats`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出默认 API 对象
|
||||
*/
|
||||
export default {
|
||||
table: tableAPI,
|
||||
database: databaseAPI,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { css } from 'lit';
|
||||
|
||||
// 共享的基础样式
|
||||
export const sharedStyles = css`
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
|
||||
export class AppContainer extends LitElement {
|
||||
static properties = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
|
||||
export class Badge extends LitElement {
|
||||
static properties = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
|
||||
export class DataView extends LitElement {
|
||||
static properties = {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { LitElement, html, css, svg } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
|
||||
export class FieldIcon extends LitElement {
|
||||
static properties = {
|
||||
indexed: { type: Boolean }
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
|
||||
export class ManifestView extends LitElement {
|
||||
static properties = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
|
||||
export class ModalDialog extends LitElement {
|
||||
static properties = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
|
||||
export class PageHeader extends LitElement {
|
||||
static properties = {
|
||||
@@ -247,11 +247,11 @@ export class PageHeader extends LitElement {
|
||||
>
|
||||
<span>Data</span>
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="view-tab ${this.view === 'manifest' ? 'active' : ''}"
|
||||
@click=${() => this.switchView('manifest')}
|
||||
>
|
||||
<span>Manifest / LSM-Tree</span>
|
||||
<span>Manifest / Storage Layers</span>
|
||||
</button>
|
||||
<button class="refresh-btn" @click=${this.refreshView} title="Refresh current view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
import { tableAPI } from '~/common/api.js';
|
||||
|
||||
export class TableList extends LitElement {
|
||||
static properties = {
|
||||
@@ -209,9 +210,7 @@ export class TableList extends LitElement {
|
||||
|
||||
async loadTables() {
|
||||
try {
|
||||
const response = await fetch('/api/tables');
|
||||
if (!response.ok) throw new Error('Failed to load tables');
|
||||
this.tables = await response.json();
|
||||
this.tables = await tableAPI.list();
|
||||
} catch (error) {
|
||||
console.error('Error loading tables:', error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles, cssVariables } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles, cssVariables } from '~/common/shared-styles.js';
|
||||
import { tableAPI } from '~/common/api.js';
|
||||
|
||||
export class TableView extends LitElement {
|
||||
static properties = {
|
||||
@@ -127,13 +128,20 @@ export class TableView extends LitElement {
|
||||
|
||||
try {
|
||||
// Load schema
|
||||
const schemaResponse = await fetch(`/api/tables/${this.tableName}/schema`);
|
||||
if (!schemaResponse.ok) throw new Error('Failed to load schema');
|
||||
this.schema = await schemaResponse.json();
|
||||
this.schema = await tableAPI.getSchema(this.tableName);
|
||||
|
||||
// Initialize selected columns (all by default)
|
||||
// Initialize selected columns from localStorage or all by default
|
||||
if (this.schema.fields) {
|
||||
this.selectedColumns = this.schema.fields.map(f => f.name);
|
||||
const saved = this.loadSelectedColumns();
|
||||
if (saved && saved.length > 0) {
|
||||
// 验证保存的列是否仍然存在于当前 schema 中
|
||||
const validColumns = saved.filter(col =>
|
||||
this.schema.fields.some(field => field.name === col)
|
||||
);
|
||||
this.selectedColumns = validColumns.length > 0 ? validColumns : this.schema.fields.map(f => f.name);
|
||||
} else {
|
||||
this.selectedColumns = this.schema.fields.map(f => f.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.view === 'data') {
|
||||
@@ -149,24 +157,28 @@ export class TableView extends LitElement {
|
||||
}
|
||||
|
||||
async loadTableData() {
|
||||
const selectParam = this.selectedColumns.join(',');
|
||||
const url = `/api/tables/${this.tableName}/data?page=${this.page}&pageSize=${this.pageSize}&select=${selectParam}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to load table data');
|
||||
this.tableData = await response.json();
|
||||
this.tableData = await tableAPI.getData(this.tableName, {
|
||||
page: this.page,
|
||||
pageSize: this.pageSize,
|
||||
select: this.selectedColumns.join(',')
|
||||
});
|
||||
}
|
||||
|
||||
async loadManifestData() {
|
||||
const response = await fetch(`/api/tables/${this.tableName}/manifest`);
|
||||
if (!response.ok) throw new Error('Failed to load manifest data');
|
||||
this.manifestData = await response.json();
|
||||
this.manifestData = await tableAPI.getManifest(this.tableName);
|
||||
}
|
||||
|
||||
switchView(newView) {
|
||||
this.view = newView;
|
||||
}
|
||||
|
||||
loadSelectedColumns() {
|
||||
if (!this.tableName) return null;
|
||||
const key = `srdb_columns_${this.tableName}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
}
|
||||
|
||||
toggleColumn(columnName) {
|
||||
const index = this.selectedColumns.indexOf(columnName);
|
||||
if (index > -1) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||
import { sharedStyles } from '../styles/shared-styles.js';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { sharedStyles } from '~/common/shared-styles.js';
|
||||
|
||||
export class ThemeToggle extends LitElement {
|
||||
static properties = {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import './components/app.js';
|
||||
import './components/table-list.js';
|
||||
import './components/table-view.js';
|
||||
import './components/modal-dialog.js';
|
||||
import './components/theme-toggle.js';
|
||||
import './components/badge.js';
|
||||
import './components/field-icon.js';
|
||||
import './components/data-view.js';
|
||||
import './components/manifest-view.js';
|
||||
import './components/page-header.js';
|
||||
import '~/components/app.js';
|
||||
import '~/components/table-list.js';
|
||||
import '~/components/table-view.js';
|
||||
import '~/components/modal-dialog.js';
|
||||
import '~/components/theme-toggle.js';
|
||||
import '~/components/badge.js';
|
||||
import '~/components/field-icon.js';
|
||||
import '~/components/data-view.js';
|
||||
import '~/components/manifest-view.js';
|
||||
import '~/components/page-header.js';
|
||||
import { tableAPI } from '~/common/api.js';
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
@@ -78,10 +79,7 @@ class App {
|
||||
|
||||
async showRowDetail(tableName, seq) {
|
||||
try {
|
||||
const response = await fetch(`/api/tables/${tableName}/data/${seq}`);
|
||||
if (!response.ok) throw new Error('Failed to load row detail');
|
||||
|
||||
const data = await response.json();
|
||||
const data = await tableAPI.getRow(tableName, seq);
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
this.modal.title = `Row Detail - Seq: ${seq}`;
|
||||
Reference in New Issue
Block a user