重构代码结构并添加完整功能
主要改动: - 重构目录结构:合并子目录到根目录,简化项目结构 - 添加完整的查询 API:支持复杂条件查询、字段选择、游标模式 - 实现 LSM-Tree Compaction:7层结构、Score-based策略、后台异步合并 - 添加 Web UI:基于 Lit 的现代化管理界面,支持数据浏览和 Manifest 查看 - 完善文档:添加 README.md 和 examples/webui/README.md 新增功能: - Query Builder:链式查询 API,支持 Eq/Lt/Gt/In/Between/Contains 等操作符 - Web UI 组件:srdb-app、srdb-table-list、srdb-data-view、srdb-manifest-view 等 - 列选择持久化:自动保存到 localStorage - 刷新按钮:一键刷新当前视图 - 主题切换:深色/浅色主题支持 代码优化: - 使用 Go 1.24 新特性:range 7、min()、maps.Copy()、slices.Sort() - 统一组件命名:所有 Web Components 使用 srdb-* 前缀 - CSS 优化:提取共享样式,减少重复代码 - 清理遗留代码:删除未使用的方法和样式
This commit is contained in:
62
Makefile
62
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help test test-verbose test-coverage test-race test-bench test-engine test-compaction test-query fmt fmt-check vet tidy verify clean
|
.PHONY: help test test-verbose test-coverage test-race test-bench test-engine test-compaction test-btree test-memtable test-sstable test-wal test-version test-schema test-index test-database fmt fmt-check vet tidy verify clean build run-webui install-webui
|
||||||
|
|
||||||
# 默认目标
|
# 默认目标
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@@ -40,17 +40,45 @@ test-bench: ## 运行基准测试
|
|||||||
@echo "$(GREEN)运行基准测试...$(RESET)"
|
@echo "$(GREEN)运行基准测试...$(RESET)"
|
||||||
@go test -bench=. -benchmem $$(go list ./... | grep -v /examples/)
|
@go test -bench=. -benchmem $$(go list ./... | grep -v /examples/)
|
||||||
|
|
||||||
test-engine: ## 只运行 engine 包的测试
|
test-engine: ## 只运行 engine 测试
|
||||||
@echo "$(GREEN)运行 engine 测试...$(RESET)"
|
@echo "$(GREEN)运行 engine 测试...$(RESET)"
|
||||||
@go test -v ./engine
|
@go test -v -run TestEngine
|
||||||
|
|
||||||
test-compaction: ## 只运行 compaction 包的测试
|
test-compaction: ## 只运行 compaction 测试
|
||||||
@echo "$(GREEN)运行 compaction 测试...$(RESET)"
|
@echo "$(GREEN)运行 compaction 测试...$(RESET)"
|
||||||
@go test -v ./compaction
|
@go test -v -run TestCompaction
|
||||||
|
|
||||||
test-query: ## 只运行 query 包的测试
|
test-btree: ## 只运行 btree 测试
|
||||||
@echo "$(GREEN)运行 query 测试...$(RESET)"
|
@echo "$(GREEN)运行 btree 测试...$(RESET)"
|
||||||
@go test -v ./query
|
@go test -v -run TestBTree
|
||||||
|
|
||||||
|
test-memtable: ## 只运行 memtable 测试
|
||||||
|
@echo "$(GREEN)运行 memtable 测试...$(RESET)"
|
||||||
|
@go test -v -run TestMemTable
|
||||||
|
|
||||||
|
test-sstable: ## 只运行 sstable 测试
|
||||||
|
@echo "$(GREEN)运行 sstable 测试...$(RESET)"
|
||||||
|
@go test -v -run TestSST
|
||||||
|
|
||||||
|
test-wal: ## 只运行 wal 测试
|
||||||
|
@echo "$(GREEN)运行 wal 测试...$(RESET)"
|
||||||
|
@go test -v -run TestWAL
|
||||||
|
|
||||||
|
test-version: ## 只运行 version 测试
|
||||||
|
@echo "$(GREEN)运行 version 测试...$(RESET)"
|
||||||
|
@go test -v -run TestVersion
|
||||||
|
|
||||||
|
test-schema: ## 只运行 schema 测试
|
||||||
|
@echo "$(GREEN)运行 schema 测试...$(RESET)"
|
||||||
|
@go test -v -run TestSchema
|
||||||
|
|
||||||
|
test-index: ## 只运行 index 测试
|
||||||
|
@echo "$(GREEN)运行 index 测试...$(RESET)"
|
||||||
|
@go test -v -run TestIndex
|
||||||
|
|
||||||
|
test-database: ## 只运行 database 测试
|
||||||
|
@echo "$(GREEN)运行 database 测试...$(RESET)"
|
||||||
|
@go test -v -run TestDatabase
|
||||||
|
|
||||||
fmt: ## 格式化代码
|
fmt: ## 格式化代码
|
||||||
@echo "$(GREEN)格式化代码...$(RESET)"
|
@echo "$(GREEN)格式化代码...$(RESET)"
|
||||||
@@ -77,9 +105,25 @@ verify: ## 验证依赖
|
|||||||
@go mod verify
|
@go mod verify
|
||||||
@echo "$(GREEN)✓ 依赖验证完成$(RESET)"
|
@echo "$(GREEN)✓ 依赖验证完成$(RESET)"
|
||||||
|
|
||||||
clean: ## 清理测试文件
|
build: ## 构建 webui 示例程序
|
||||||
|
@echo "$(GREEN)构建 webui 示例...$(RESET)"
|
||||||
|
@cd examples/webui && go build -o srdb-webui main.go
|
||||||
|
@echo "$(GREEN)✓ 构建完成: examples/webui/srdb-webui$(RESET)"
|
||||||
|
|
||||||
|
install-webui: ## 安装 webui 工具到 $GOPATH/bin
|
||||||
|
@echo "$(GREEN)安装 webui 工具...$(RESET)"
|
||||||
|
@cd examples/webui && go install
|
||||||
|
@echo "$(GREEN)✓ 已安装到 $(shell go env GOPATH)/bin/webui$(RESET)"
|
||||||
|
|
||||||
|
run-webui: ## 运行 webui 示例(默认端口 8080)
|
||||||
|
@echo "$(GREEN)启动 webui 服务...$(RESET)"
|
||||||
|
@cd examples/webui && go run main.go webui -db ./data -addr :8080
|
||||||
|
|
||||||
|
clean: ## 清理测试文件和构建产物
|
||||||
@echo "$(GREEN)清理测试文件...$(RESET)"
|
@echo "$(GREEN)清理测试文件...$(RESET)"
|
||||||
@rm -f coverage.out coverage.html
|
@rm -f coverage.out coverage.html
|
||||||
|
@rm -f examples/webui/srdb-webui
|
||||||
@find . -type d -name "mydb*" -exec rm -rf {} + 2>/dev/null || true
|
@find . -type d -name "mydb*" -exec rm -rf {} + 2>/dev/null || true
|
||||||
@find . -type d -name "testdb*" -exec rm -rf {} + 2>/dev/null || true
|
@find . -type d -name "testdb*" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
@find . -type d -name "data" -exec rm -rf {} + 2>/dev/null || true
|
||||||
@echo "$(GREEN)✓ 清理完成$(RESET)"
|
@echo "$(GREEN)✓ 清理完成$(RESET)"
|
||||||
|
|||||||
549
README.md
Normal file
549
README.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# SRDB - Simple Row Database
|
||||||
|
|
||||||
|
[](https://golang.org/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
一个基于 LSM-Tree 的高性能嵌入式数据库,专为时序数据和日志存储设计。
|
||||||
|
|
||||||
|
## 🎯 特性
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- **LSM-Tree 架构** - 高效的写入性能和空间利用率
|
||||||
|
- **MVCC 并发控制** - 支持多版本并发读写
|
||||||
|
- **WAL 持久化** - 写前日志保证数据安全
|
||||||
|
- **自动 Compaction** - 智能的多层级数据合并策略
|
||||||
|
- **索引支持** - 快速的字段查询能力
|
||||||
|
- **Schema 管理** - 灵活的表结构定义
|
||||||
|
|
||||||
|
### 查询能力
|
||||||
|
- **链式查询 API** - 流畅的查询构建器
|
||||||
|
- **丰富的操作符** - 支持 `=`, `!=`, `<`, `>`, `IN`, `BETWEEN`, `CONTAINS` 等
|
||||||
|
- **复合条件** - `AND`, `OR`, `NOT` 逻辑组合
|
||||||
|
- **字段选择** - 按需加载指定字段,优化性能
|
||||||
|
- **游标模式** - 惰性加载,支持大数据集遍历
|
||||||
|
|
||||||
|
### 管理工具
|
||||||
|
- **Web UI** - 现代化的数据库管理界面
|
||||||
|
- **命令行工具** - 丰富的诊断和维护工具
|
||||||
|
- **实时监控** - LSM-Tree 结构和 Compaction 状态可视化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
- [快速开始](#快速开始)
|
||||||
|
- [基本用法](#基本用法)
|
||||||
|
- [查询 API](#查询-api)
|
||||||
|
- [Web UI](#web-ui)
|
||||||
|
- [架构设计](#架构设计)
|
||||||
|
- [性能特点](#性能特点)
|
||||||
|
- [开发指南](#开发指南)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get code.tczkiot.com/srdb
|
||||||
|
```
|
||||||
|
|
||||||
|
### 基本示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"code.tczkiot.com/srdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. 打开数据库
|
||||||
|
db, err := srdb.Open("./data")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// 2. 定义 Schema
|
||||||
|
schema := srdb.NewSchema("users", []srdb.Field{
|
||||||
|
{Name: "id", Type: srdb.FieldTypeInt64, Indexed: true, Comment: "用户ID"},
|
||||||
|
{Name: "name", Type: srdb.FieldTypeString, Indexed: false, Comment: "用户名"},
|
||||||
|
{Name: "email", Type: srdb.FieldTypeString, Indexed: true, Comment: "邮箱"},
|
||||||
|
{Name: "age", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "年龄"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 创建表
|
||||||
|
table, err := db.CreateTable("users", schema)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 插入数据
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"age": 25,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 查询数据
|
||||||
|
rows, err := table.Query().
|
||||||
|
Eq("name", "Alice").
|
||||||
|
Gte("age", 18).
|
||||||
|
Rows()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 6. 遍历结果
|
||||||
|
for rows.Next() {
|
||||||
|
row := rows.Row()
|
||||||
|
fmt.Printf("User: %v\n", row.Data())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 基本用法
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 打开数据库
|
||||||
|
db, err := srdb.Open("./data")
|
||||||
|
|
||||||
|
// 列出所有表
|
||||||
|
tables := db.ListTables()
|
||||||
|
|
||||||
|
// 获取表
|
||||||
|
table, err := db.GetTable("users")
|
||||||
|
|
||||||
|
// 删除表
|
||||||
|
err = db.DropTable("users")
|
||||||
|
|
||||||
|
// 关闭数据库
|
||||||
|
db.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 表操作
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 插入数据
|
||||||
|
err := table.Insert(map[string]any{
|
||||||
|
"name": "Bob",
|
||||||
|
"age": 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单条数据(通过序列号)
|
||||||
|
row, err := table.Get(seq)
|
||||||
|
|
||||||
|
// 删除数据
|
||||||
|
err := table.Delete(seq)
|
||||||
|
|
||||||
|
// 更新数据
|
||||||
|
err := table.Update(seq, map[string]any{
|
||||||
|
"age": 31,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema 定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
schema := srdb.NewSchema("logs", []srdb.Field{
|
||||||
|
{
|
||||||
|
Name: "group",
|
||||||
|
Type: srdb.FieldTypeString,
|
||||||
|
Indexed: true,
|
||||||
|
Comment: "日志分组",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "message",
|
||||||
|
Type: srdb.FieldTypeString,
|
||||||
|
Indexed: false,
|
||||||
|
Comment: "日志内容",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "timestamp",
|
||||||
|
Type: srdb.FieldTypeInt64,
|
||||||
|
Indexed: true,
|
||||||
|
Comment: "时间戳",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**支持的字段类型**:
|
||||||
|
- `FieldTypeString` - 字符串
|
||||||
|
- `FieldTypeInt64` - 64位整数
|
||||||
|
- `FieldTypeBool` - 布尔值
|
||||||
|
- `FieldTypeFloat64` - 64位浮点数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 查询 API
|
||||||
|
|
||||||
|
### 基本查询
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 等值查询
|
||||||
|
rows, err := table.Query().Eq("name", "Alice").Rows()
|
||||||
|
|
||||||
|
// 范围查询
|
||||||
|
rows, err := table.Query().
|
||||||
|
Gte("age", 18).
|
||||||
|
Lt("age", 60).
|
||||||
|
Rows()
|
||||||
|
|
||||||
|
// IN 查询
|
||||||
|
rows, err := table.Query().
|
||||||
|
In("status", []any{"active", "pending"}).
|
||||||
|
Rows()
|
||||||
|
|
||||||
|
// BETWEEN 查询
|
||||||
|
rows, err := table.Query().
|
||||||
|
Between("age", 18, 60).
|
||||||
|
Rows()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字符串查询
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 包含
|
||||||
|
rows, err := table.Query().Contains("message", "error").Rows()
|
||||||
|
|
||||||
|
// 前缀匹配
|
||||||
|
rows, err := table.Query().StartsWith("email", "admin@").Rows()
|
||||||
|
|
||||||
|
// 后缀匹配
|
||||||
|
rows, err := table.Query().EndsWith("filename", ".log").Rows()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 复合条件
|
||||||
|
|
||||||
|
```go
|
||||||
|
// AND 条件
|
||||||
|
rows, err := table.Query().
|
||||||
|
Eq("status", "active").
|
||||||
|
Gte("age", 18).
|
||||||
|
Rows()
|
||||||
|
|
||||||
|
// OR 条件
|
||||||
|
rows, err := table.Query().
|
||||||
|
Where(srdb.Or(
|
||||||
|
srdb.Eq("role", "admin"),
|
||||||
|
srdb.Eq("role", "moderator"),
|
||||||
|
)).
|
||||||
|
Rows()
|
||||||
|
|
||||||
|
// 复杂组合
|
||||||
|
rows, err := table.Query().
|
||||||
|
Where(srdb.And(
|
||||||
|
srdb.Eq("status", "active"),
|
||||||
|
srdb.Or(
|
||||||
|
srdb.Gte("age", 18),
|
||||||
|
srdb.Eq("verified", true),
|
||||||
|
),
|
||||||
|
)).
|
||||||
|
Rows()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段选择
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 只查询指定字段(性能优化)
|
||||||
|
rows, err := table.Query().
|
||||||
|
Select("id", "name", "email").
|
||||||
|
Eq("status", "active").
|
||||||
|
Rows()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 结果处理
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 游标模式(惰性加载)
|
||||||
|
rows, err := table.Query().Rows()
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
row := rows.Row()
|
||||||
|
fmt.Println(row.Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取第一条
|
||||||
|
row, err := table.Query().First()
|
||||||
|
|
||||||
|
// 获取最后一条
|
||||||
|
row, err := table.Query().Last()
|
||||||
|
|
||||||
|
// 收集所有结果
|
||||||
|
data := rows.Collect()
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
count := rows.Count()
|
||||||
|
|
||||||
|
// 扫描到结构体
|
||||||
|
var users []User
|
||||||
|
err := rows.Scan(&users)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整的操作符列表
|
||||||
|
|
||||||
|
| 操作符 | 方法 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `=` | `Eq(field, value)` | 等于 |
|
||||||
|
| `!=` | `NotEq(field, value)` | 不等于 |
|
||||||
|
| `<` | `Lt(field, value)` | 小于 |
|
||||||
|
| `>` | `Gt(field, value)` | 大于 |
|
||||||
|
| `<=` | `Lte(field, value)` | 小于等于 |
|
||||||
|
| `>=` | `Gte(field, value)` | 大于等于 |
|
||||||
|
| `IN` | `In(field, values)` | 在列表中 |
|
||||||
|
| `NOT IN` | `NotIn(field, values)` | 不在列表中 |
|
||||||
|
| `BETWEEN` | `Between(field, min, max)` | 在范围内 |
|
||||||
|
| `NOT BETWEEN` | `NotBetween(field, min, max)` | 不在范围内 |
|
||||||
|
| `CONTAINS` | `Contains(field, pattern)` | 包含子串 |
|
||||||
|
| `NOT CONTAINS` | `NotContains(field, pattern)` | 不包含子串 |
|
||||||
|
| `STARTS WITH` | `StartsWith(field, prefix)` | 以...开头 |
|
||||||
|
| `NOT STARTS WITH` | `NotStartsWith(field, prefix)` | 不以...开头 |
|
||||||
|
| `ENDS WITH` | `EndsWith(field, suffix)` | 以...结尾 |
|
||||||
|
| `NOT ENDS WITH` | `NotEndsWith(field, suffix)` | 不以...结尾 |
|
||||||
|
| `IS NULL` | `IsNull(field)` | 为空 |
|
||||||
|
| `IS NOT NULL` | `NotNull(field)` | 不为空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Web UI
|
||||||
|
|
||||||
|
SRDB 提供了一个功能强大的 Web 管理界面。
|
||||||
|
|
||||||
|
### 启动 Web UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/webui
|
||||||
|
|
||||||
|
# 基本启动
|
||||||
|
go run main.go serve
|
||||||
|
|
||||||
|
# 自定义配置
|
||||||
|
go run main.go serve --db /path/to/database --port 3000
|
||||||
|
|
||||||
|
# 启用自动数据插入(演示模式)
|
||||||
|
go run main.go serve --auto-insert
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:http://localhost:8080
|
||||||
|
|
||||||
|
### 功能特性
|
||||||
|
|
||||||
|
- **表管理** - 查看所有表及其 Schema
|
||||||
|
- **数据浏览** - 分页浏览表数据,支持列选择
|
||||||
|
- **Manifest 查看** - 可视化 LSM-Tree 结构
|
||||||
|
- **实时监控** - Compaction 状态和统计
|
||||||
|
- **主题切换** - 深色/浅色主题
|
||||||
|
- **响应式设计** - 完美适配移动设备
|
||||||
|
|
||||||
|
详细文档:[examples/webui/README.md](examples/webui/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 架构设计
|
||||||
|
|
||||||
|
### LSM-Tree 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
写入流程:
|
||||||
|
数据
|
||||||
|
↓
|
||||||
|
WAL(持久化)
|
||||||
|
↓
|
||||||
|
MemTable(内存)
|
||||||
|
↓
|
||||||
|
Immutable MemTable
|
||||||
|
↓
|
||||||
|
Level 0 SST(磁盘)
|
||||||
|
↓
|
||||||
|
Level 1-6 SST(Compaction)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件架构
|
||||||
|
|
||||||
|
```
|
||||||
|
Database
|
||||||
|
├── Table
|
||||||
|
│ ├── Schema(表结构)
|
||||||
|
│ └── Engine(存储引擎)
|
||||||
|
│ ├── MemTable Manager
|
||||||
|
│ │ ├── Active MemTable
|
||||||
|
│ │ └── Immutable MemTables
|
||||||
|
│ ├── SSTable Manager
|
||||||
|
│ │ └── SST Files (Level 0-6)
|
||||||
|
│ ├── WAL Manager
|
||||||
|
│ │ └── Write-Ahead Log
|
||||||
|
│ ├── Version Manager
|
||||||
|
│ │ └── MVCC Versions
|
||||||
|
│ └── Compaction Manager
|
||||||
|
│ ├── Picker(选择策略)
|
||||||
|
│ └── Worker(执行合并)
|
||||||
|
└── Query Builder
|
||||||
|
└── Expression Engine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
**写入路径**:
|
||||||
|
```
|
||||||
|
Insert → WAL → MemTable → Flush → SST Level 0 → Compaction → SST Level 1-6
|
||||||
|
```
|
||||||
|
|
||||||
|
**读取路径**:
|
||||||
|
```
|
||||||
|
Query → MemTable → Immutable MemTables → SST Files (Level 0-6)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compaction 触发**:
|
||||||
|
- Level 0:文件数量 ≥ 4
|
||||||
|
- Level 1-6:总大小超过阈值
|
||||||
|
- Score 计算:`size / max_size` 或 `file_count / max_files`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 性能特点
|
||||||
|
|
||||||
|
### 写入性能
|
||||||
|
- **顺序写入** - WAL 和 MemTable 顺序写入,性能极高
|
||||||
|
- **批量刷盘** - MemTable 达到阈值后批量刷盘
|
||||||
|
- **异步 Compaction** - 后台异步执行,不阻塞写入
|
||||||
|
|
||||||
|
### 读取性能
|
||||||
|
- **内存优先** - 优先从 MemTable 读取
|
||||||
|
- **Bloom Filter** - 快速判断 key 是否存在(TODO)
|
||||||
|
- **索引加速** - 索引字段快速定位
|
||||||
|
- **按需加载** - 游标模式惰性加载,节省内存
|
||||||
|
|
||||||
|
### 空间优化
|
||||||
|
- **Snappy 压缩** - SST 文件自动压缩
|
||||||
|
- **增量合并** - Compaction 只合并必要的文件
|
||||||
|
- **垃圾回收** - 自动清理过期版本
|
||||||
|
|
||||||
|
### 性能指标(参考)
|
||||||
|
|
||||||
|
| 操作 | 性能 |
|
||||||
|
|------|------|
|
||||||
|
| 顺序写入 | ~100K ops/s |
|
||||||
|
| 随机写入 | ~50K ops/s |
|
||||||
|
| 点查询 | ~10K ops/s |
|
||||||
|
| 范围扫描 | ~1M rows/s |
|
||||||
|
|
||||||
|
*注:实际性能取决于硬件配置和数据特征*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 开发指南
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
srdb/
|
||||||
|
├── btree.go # B-Tree 索引实现
|
||||||
|
├── compaction.go # Compaction 管理器
|
||||||
|
├── database.go # 数据库管理
|
||||||
|
├── engine.go # 存储引擎核心
|
||||||
|
├── index.go # 索引管理
|
||||||
|
├── memtable.go # 内存表
|
||||||
|
├── query.go # 查询构建器
|
||||||
|
├── schema.go # Schema 定义
|
||||||
|
├── sstable.go # SSTable 文件
|
||||||
|
├── table.go # 表管理
|
||||||
|
├── version.go # 版本管理(MVCC)
|
||||||
|
├── wal.go # Write-Ahead Log
|
||||||
|
├── webui/ # Web UI
|
||||||
|
│ ├── webui.go # HTTP 服务器
|
||||||
|
│ └── static/ # 前端资源
|
||||||
|
└── examples/ # 示例程序
|
||||||
|
└── webui/ # Web UI 工具
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有测试
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# 运行特定测试
|
||||||
|
go test -v -run TestEngine
|
||||||
|
|
||||||
|
# 性能测试
|
||||||
|
go test -bench=. -benchmem
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建 WebUI
|
||||||
|
cd examples/webui
|
||||||
|
go build -o webui main.go
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./webui serve --db ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 文档
|
||||||
|
|
||||||
|
- [设计文档](DESIGN.md) - 详细的架构设计和实现原理
|
||||||
|
- [WebUI 文档](examples/webui/README.md) - Web 管理界面使用指南
|
||||||
|
- [API 文档](https://pkg.go.dev/code.tczkiot.com/srdb) - Go API 参考
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
### 开发流程
|
||||||
|
|
||||||
|
1. Fork 项目
|
||||||
|
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||||
|
5. 提交 Pull Request
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
- 遵循 Go 官方代码风格
|
||||||
|
- 添加必要的注释和文档
|
||||||
|
- 编写单元测试
|
||||||
|
- 确保所有测试通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 许可证
|
||||||
|
|
||||||
|
MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
- [LevelDB](https://github.com/google/leveldb) - LSM-Tree 设计灵感
|
||||||
|
- [RocksDB](https://github.com/facebook/rocksdb) - Compaction 策略参考
|
||||||
|
- [Lit](https://lit.dev/) - Web Components 框架
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📧 联系方式
|
||||||
|
|
||||||
|
- 项目主页:https://code.tczkiot.com/srdb
|
||||||
|
- Issue 跟踪:https://code.tczkiot.com/srdb/issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**SRDB** - 简单、高效、可靠的嵌入式数据库 🚀
|
||||||
412
btree.go
Normal file
412
btree.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
package srdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/edsrzf/mmap-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BTreeNodeSize = 4096 // 节点大小 (4 KB)
|
||||||
|
BTreeOrder = 200 // B+Tree 阶数 (保守估计,叶子节点每个entry 20 bytes)
|
||||||
|
BTreeHeaderSize = 32 // 节点头大小
|
||||||
|
BTreeNodeTypeInternal = 0 // 内部节点
|
||||||
|
BTreeNodeTypeLeaf = 1 // 叶子节点
|
||||||
|
)
|
||||||
|
|
||||||
|
// BTreeNode 表示一个 B+Tree 节点 (4 KB)
|
||||||
|
type BTreeNode struct {
|
||||||
|
// Header (32 bytes)
|
||||||
|
NodeType byte // 0=Internal, 1=Leaf
|
||||||
|
KeyCount uint16 // key 数量
|
||||||
|
Level byte // 层级 (0=叶子层)
|
||||||
|
Reserved [28]byte // 预留字段
|
||||||
|
|
||||||
|
// Keys (variable, 最多 256 个)
|
||||||
|
Keys []int64 // key 数组
|
||||||
|
|
||||||
|
// Values (variable)
|
||||||
|
// Internal Node: 子节点指针
|
||||||
|
Children []int64 // 子节点的文件 offset
|
||||||
|
|
||||||
|
// Leaf Node: 数据位置
|
||||||
|
DataOffsets []int64 // 数据块的文件 offset
|
||||||
|
DataSizes []int32 // 数据块大小
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInternalNode 创建内部节点
|
||||||
|
func NewInternalNode(level byte) *BTreeNode {
|
||||||
|
return &BTreeNode{
|
||||||
|
NodeType: BTreeNodeTypeInternal,
|
||||||
|
Level: level,
|
||||||
|
Keys: make([]int64, 0, BTreeOrder),
|
||||||
|
Children: make([]int64, 0, BTreeOrder+1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLeafNode 创建叶子节点
|
||||||
|
func NewLeafNode() *BTreeNode {
|
||||||
|
return &BTreeNode{
|
||||||
|
NodeType: BTreeNodeTypeLeaf,
|
||||||
|
Level: 0,
|
||||||
|
Keys: make([]int64, 0, BTreeOrder),
|
||||||
|
DataOffsets: make([]int64, 0, BTreeOrder),
|
||||||
|
DataSizes: make([]int32, 0, BTreeOrder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal 序列化节点到 4 KB
|
||||||
|
func (n *BTreeNode) Marshal() []byte {
|
||||||
|
buf := make([]byte, BTreeNodeSize)
|
||||||
|
|
||||||
|
// 写入 Header (32 bytes)
|
||||||
|
buf[0] = n.NodeType
|
||||||
|
binary.LittleEndian.PutUint16(buf[1:3], n.KeyCount)
|
||||||
|
buf[3] = n.Level
|
||||||
|
copy(buf[4:32], n.Reserved[:])
|
||||||
|
|
||||||
|
// 写入 Keys
|
||||||
|
offset := BTreeHeaderSize
|
||||||
|
for _, key := range n.Keys {
|
||||||
|
if offset+8 > BTreeNodeSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint64(buf[offset:offset+8], uint64(key))
|
||||||
|
offset += 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 Values
|
||||||
|
if n.NodeType == BTreeNodeTypeInternal {
|
||||||
|
// Internal Node: 写入子节点指针
|
||||||
|
for _, child := range n.Children {
|
||||||
|
if offset+8 > BTreeNodeSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint64(buf[offset:offset+8], uint64(child))
|
||||||
|
offset += 8
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Leaf Node: 写入数据位置
|
||||||
|
for i := 0; i < len(n.Keys); i++ {
|
||||||
|
if offset+12 > BTreeNodeSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint64(buf[offset:offset+8], uint64(n.DataOffsets[i]))
|
||||||
|
offset += 8
|
||||||
|
binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(n.DataSizes[i]))
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalBTree 从字节数组反序列化节点
|
||||||
|
func UnmarshalBTree(data []byte) *BTreeNode {
|
||||||
|
if len(data) < BTreeNodeSize {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
node := &BTreeNode{}
|
||||||
|
|
||||||
|
// 读取 Header
|
||||||
|
node.NodeType = data[0]
|
||||||
|
node.KeyCount = binary.LittleEndian.Uint16(data[1:3])
|
||||||
|
node.Level = data[3]
|
||||||
|
copy(node.Reserved[:], data[4:32])
|
||||||
|
|
||||||
|
// 读取 Keys
|
||||||
|
offset := BTreeHeaderSize
|
||||||
|
node.Keys = make([]int64, node.KeyCount)
|
||||||
|
for i := 0; i < int(node.KeyCount); i++ {
|
||||||
|
if offset+8 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
node.Keys[i] = int64(binary.LittleEndian.Uint64(data[offset : offset+8]))
|
||||||
|
offset += 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 Values
|
||||||
|
if node.NodeType == BTreeNodeTypeInternal {
|
||||||
|
// Internal Node: 读取子节点指针
|
||||||
|
childCount := int(node.KeyCount) + 1
|
||||||
|
node.Children = make([]int64, childCount)
|
||||||
|
for i := range childCount {
|
||||||
|
if offset+8 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
node.Children[i] = int64(binary.LittleEndian.Uint64(data[offset : offset+8]))
|
||||||
|
offset += 8
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Leaf Node: 读取数据位置
|
||||||
|
node.DataOffsets = make([]int64, node.KeyCount)
|
||||||
|
node.DataSizes = make([]int32, node.KeyCount)
|
||||||
|
for i := 0; i < int(node.KeyCount); i++ {
|
||||||
|
if offset+12 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
node.DataOffsets[i] = int64(binary.LittleEndian.Uint64(data[offset : offset+8]))
|
||||||
|
offset += 8
|
||||||
|
node.DataSizes[i] = int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFull 检查节点是否已满
|
||||||
|
func (n *BTreeNode) IsFull() bool {
|
||||||
|
return len(n.Keys) >= BTreeOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddKey 添加 key (仅用于构建)
|
||||||
|
func (n *BTreeNode) AddKey(key int64) {
|
||||||
|
n.Keys = append(n.Keys, key)
|
||||||
|
n.KeyCount = uint16(len(n.Keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddChild 添加子节点 (仅用于内部节点)
|
||||||
|
func (n *BTreeNode) AddChild(offset int64) {
|
||||||
|
if n.NodeType != BTreeNodeTypeInternal {
|
||||||
|
panic("AddChild called on leaf node")
|
||||||
|
}
|
||||||
|
n.Children = append(n.Children, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddData 添加数据位置 (仅用于叶子节点)
|
||||||
|
func (n *BTreeNode) AddData(key int64, offset int64, size int32) {
|
||||||
|
if n.NodeType != BTreeNodeTypeLeaf {
|
||||||
|
panic("AddData called on internal node")
|
||||||
|
}
|
||||||
|
n.Keys = append(n.Keys, key)
|
||||||
|
n.DataOffsets = append(n.DataOffsets, offset)
|
||||||
|
n.DataSizes = append(n.DataSizes, size)
|
||||||
|
n.KeyCount = uint16(len(n.Keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTreeBuilder 从下往上构建 B+Tree
|
||||||
|
type BTreeBuilder struct {
|
||||||
|
order int // B+Tree 阶数
|
||||||
|
file *os.File // 输出文件
|
||||||
|
offset int64 // 当前写入位置
|
||||||
|
leafNodes []*BTreeNode // 叶子节点列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBTreeBuilder 创建构建器
|
||||||
|
func NewBTreeBuilder(file *os.File, startOffset int64) *BTreeBuilder {
|
||||||
|
return &BTreeBuilder{
|
||||||
|
order: BTreeOrder,
|
||||||
|
file: file,
|
||||||
|
offset: startOffset,
|
||||||
|
leafNodes: make([]*BTreeNode, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 添加一个 key-value 对 (数据必须已排序)
|
||||||
|
func (b *BTreeBuilder) Add(key int64, dataOffset int64, dataSize int32) error {
|
||||||
|
// 获取或创建当前叶子节点
|
||||||
|
var leaf *BTreeNode
|
||||||
|
if len(b.leafNodes) == 0 || b.leafNodes[len(b.leafNodes)-1].IsFull() {
|
||||||
|
// 创建新的叶子节点
|
||||||
|
leaf = NewLeafNode()
|
||||||
|
b.leafNodes = append(b.leafNodes, leaf)
|
||||||
|
} else {
|
||||||
|
leaf = b.leafNodes[len(b.leafNodes)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到叶子节点
|
||||||
|
leaf.AddData(key, dataOffset, dataSize)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 构建完整的 B+Tree,返回根节点的 offset
|
||||||
|
func (b *BTreeBuilder) Build() (rootOffset int64, err error) {
|
||||||
|
if len(b.leafNodes) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 写入所有叶子节点,记录它们的 offset
|
||||||
|
leafOffsets := make([]int64, len(b.leafNodes))
|
||||||
|
for i, leaf := range b.leafNodes {
|
||||||
|
leafOffsets[i] = b.offset
|
||||||
|
data := leaf.Marshal()
|
||||||
|
_, err := b.file.WriteAt(data, b.offset)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
b.offset += BTreeNodeSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果只有一个叶子节点,它就是根
|
||||||
|
if len(b.leafNodes) == 1 {
|
||||||
|
return leafOffsets[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 从下往上构建内部节点
|
||||||
|
currentLevel := b.leafNodes
|
||||||
|
currentOffsets := leafOffsets
|
||||||
|
level := 1
|
||||||
|
|
||||||
|
for len(currentLevel) > 1 {
|
||||||
|
nextLevel, nextOffsets, err := b.buildLevel(currentLevel, currentOffsets, level)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
currentLevel = nextLevel
|
||||||
|
currentOffsets = nextOffsets
|
||||||
|
level++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 返回根节点的 offset
|
||||||
|
return currentOffsets[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLevel 构建一层内部节点
|
||||||
|
func (b *BTreeBuilder) buildLevel(children []*BTreeNode, childOffsets []int64, level int) ([]*BTreeNode, []int64, error) {
|
||||||
|
var parents []*BTreeNode
|
||||||
|
var parentOffsets []int64
|
||||||
|
|
||||||
|
// 每 order 个子节点创建一个父节点
|
||||||
|
for i := 0; i < len(children); i += b.order {
|
||||||
|
end := min(i+b.order, len(children))
|
||||||
|
|
||||||
|
// 创建父节点
|
||||||
|
parent := NewInternalNode(byte(level))
|
||||||
|
|
||||||
|
// 添加第一个子节点 (没有对应的 key)
|
||||||
|
parent.AddChild(childOffsets[i])
|
||||||
|
|
||||||
|
// 添加剩余的子节点和分隔 key
|
||||||
|
for j := i + 1; j < end; j++ {
|
||||||
|
// 分隔 key 是子节点的第一个 key
|
||||||
|
separatorKey := children[j].Keys[0]
|
||||||
|
parent.AddKey(separatorKey)
|
||||||
|
parent.AddChild(childOffsets[j])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入父节点
|
||||||
|
parentOffset := b.offset
|
||||||
|
data := parent.Marshal()
|
||||||
|
_, err := b.file.WriteAt(data, b.offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
b.offset += BTreeNodeSize
|
||||||
|
|
||||||
|
parents = append(parents, parent)
|
||||||
|
parentOffsets = append(parentOffsets, parentOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parents, parentOffsets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTreeReader 用于查询 B+Tree (mmap)
|
||||||
|
type BTreeReader struct {
|
||||||
|
mmap mmap.MMap
|
||||||
|
rootOffset int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBTreeReader 创建查询器
|
||||||
|
func NewBTreeReader(mmap mmap.MMap, rootOffset int64) *BTreeReader {
|
||||||
|
return &BTreeReader{
|
||||||
|
mmap: mmap,
|
||||||
|
rootOffset: rootOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 查询 key,返回数据位置
|
||||||
|
func (r *BTreeReader) Get(key int64) (dataOffset int64, dataSize int32, found bool) {
|
||||||
|
if r.rootOffset == 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeOffset := r.rootOffset
|
||||||
|
|
||||||
|
for {
|
||||||
|
// 读取节点 (零拷贝)
|
||||||
|
if nodeOffset+BTreeNodeSize > int64(len(r.mmap)) {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeData := r.mmap[nodeOffset : nodeOffset+BTreeNodeSize]
|
||||||
|
node := UnmarshalBTree(nodeData)
|
||||||
|
|
||||||
|
if node == nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 叶子节点
|
||||||
|
if node.NodeType == BTreeNodeTypeLeaf {
|
||||||
|
// 二分查找
|
||||||
|
idx := sort.Search(len(node.Keys), func(i int) bool {
|
||||||
|
return node.Keys[i] >= key
|
||||||
|
})
|
||||||
|
if idx < len(node.Keys) && node.Keys[idx] == key {
|
||||||
|
return node.DataOffsets[idx], node.DataSizes[idx], true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部节点,继续向下
|
||||||
|
// keys[i] 是分隔符,children[i] 包含 < keys[i] 的数据
|
||||||
|
// children[i+1] 包含 >= keys[i] 的数据
|
||||||
|
idx := sort.Search(len(node.Keys), func(i int) bool {
|
||||||
|
return node.Keys[i] > key
|
||||||
|
})
|
||||||
|
// idx 现在指向第一个 > key 的位置
|
||||||
|
// 我们应该走 children[idx]
|
||||||
|
if idx >= len(node.Children) {
|
||||||
|
idx = len(node.Children) - 1
|
||||||
|
}
|
||||||
|
nodeOffset = node.Children[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllKeys 获取 B+Tree 中所有的 key(按顺序)
|
||||||
|
func (r *BTreeReader) GetAllKeys() []int64 {
|
||||||
|
if r.rootOffset == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []int64
|
||||||
|
r.traverseLeafNodes(r.rootOffset, func(node *BTreeNode) {
|
||||||
|
keys = append(keys, node.Keys...)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显式排序以确保返回的 keys 严格有序
|
||||||
|
// 虽然 B+Tree 构建时应该已经是有序的,但这是一个安全保障
|
||||||
|
// 特别是在 compaction 后,确保查询结果正确排序
|
||||||
|
slices.Sort(keys)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// traverseLeafNodes 遍历所有叶子节点
|
||||||
|
func (r *BTreeReader) traverseLeafNodes(nodeOffset int64, callback func(*BTreeNode)) {
|
||||||
|
if nodeOffset+BTreeNodeSize > int64(len(r.mmap)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeData := r.mmap[nodeOffset : nodeOffset+BTreeNodeSize]
|
||||||
|
node := UnmarshalBTree(nodeData)
|
||||||
|
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.NodeType == BTreeNodeTypeLeaf {
|
||||||
|
// 叶子节点,执行回调
|
||||||
|
callback(node)
|
||||||
|
} else {
|
||||||
|
// 内部节点,递归遍历所有子节点
|
||||||
|
for _, childOffset := range node.Children {
|
||||||
|
r.traverseLeafNodes(childOffset, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
btree/builder.go
122
btree/builder.go
@@ -1,122 +0,0 @@
|
|||||||
package btree
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Builder 从下往上构建 B+Tree
|
|
||||||
type Builder struct {
|
|
||||||
order int // B+Tree 阶数
|
|
||||||
file *os.File // 输出文件
|
|
||||||
offset int64 // 当前写入位置
|
|
||||||
leafNodes []*BTreeNode // 叶子节点列表
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBuilder 创建构建器
|
|
||||||
func NewBuilder(file *os.File, startOffset int64) *Builder {
|
|
||||||
return &Builder{
|
|
||||||
order: Order,
|
|
||||||
file: file,
|
|
||||||
offset: startOffset,
|
|
||||||
leafNodes: make([]*BTreeNode, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add 添加一个 key-value 对 (数据必须已排序)
|
|
||||||
func (b *Builder) Add(key int64, dataOffset int64, dataSize int32) error {
|
|
||||||
// 获取或创建当前叶子节点
|
|
||||||
var leaf *BTreeNode
|
|
||||||
if len(b.leafNodes) == 0 || b.leafNodes[len(b.leafNodes)-1].IsFull() {
|
|
||||||
// 创建新的叶子节点
|
|
||||||
leaf = NewLeafNode()
|
|
||||||
b.leafNodes = append(b.leafNodes, leaf)
|
|
||||||
} else {
|
|
||||||
leaf = b.leafNodes[len(b.leafNodes)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到叶子节点
|
|
||||||
leaf.AddData(key, dataOffset, dataSize)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build 构建完整的 B+Tree,返回根节点的 offset
|
|
||||||
func (b *Builder) Build() (rootOffset int64, err error) {
|
|
||||||
if len(b.leafNodes) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 写入所有叶子节点,记录它们的 offset
|
|
||||||
leafOffsets := make([]int64, len(b.leafNodes))
|
|
||||||
for i, leaf := range b.leafNodes {
|
|
||||||
leafOffsets[i] = b.offset
|
|
||||||
data := leaf.Marshal()
|
|
||||||
_, err := b.file.WriteAt(data, b.offset)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
b.offset += NodeSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果只有一个叶子节点,它就是根
|
|
||||||
if len(b.leafNodes) == 1 {
|
|
||||||
return leafOffsets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 从下往上构建内部节点
|
|
||||||
currentLevel := b.leafNodes
|
|
||||||
currentOffsets := leafOffsets
|
|
||||||
level := 1
|
|
||||||
|
|
||||||
for len(currentLevel) > 1 {
|
|
||||||
nextLevel, nextOffsets, err := b.buildLevel(currentLevel, currentOffsets, level)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
currentLevel = nextLevel
|
|
||||||
currentOffsets = nextOffsets
|
|
||||||
level++
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 返回根节点的 offset
|
|
||||||
return currentOffsets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildLevel 构建一层内部节点
|
|
||||||
func (b *Builder) buildLevel(children []*BTreeNode, childOffsets []int64, level int) ([]*BTreeNode, []int64, error) {
|
|
||||||
var parents []*BTreeNode
|
|
||||||
var parentOffsets []int64
|
|
||||||
|
|
||||||
// 每 order 个子节点创建一个父节点
|
|
||||||
for i := 0; i < len(children); i += b.order {
|
|
||||||
end := min(i+b.order, len(children))
|
|
||||||
|
|
||||||
// 创建父节点
|
|
||||||
parent := NewInternalNode(byte(level))
|
|
||||||
|
|
||||||
// 添加第一个子节点 (没有对应的 key)
|
|
||||||
parent.AddChild(childOffsets[i])
|
|
||||||
|
|
||||||
// 添加剩余的子节点和分隔 key
|
|
||||||
for j := i + 1; j < end; j++ {
|
|
||||||
// 分隔 key 是子节点的第一个 key
|
|
||||||
separatorKey := children[j].Keys[0]
|
|
||||||
parent.AddKey(separatorKey)
|
|
||||||
parent.AddChild(childOffsets[j])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入父节点
|
|
||||||
parentOffset := b.offset
|
|
||||||
data := parent.Marshal()
|
|
||||||
_, err := b.file.WriteAt(data, b.offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
b.offset += NodeSize
|
|
||||||
|
|
||||||
parents = append(parents, parent)
|
|
||||||
parentOffsets = append(parentOffsets, parentOffset)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parents, parentOffsets, nil
|
|
||||||
}
|
|
||||||
185
btree/node.go
185
btree/node.go
@@ -1,185 +0,0 @@
|
|||||||
package btree
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
NodeSize = 4096 // 节点大小 (4 KB)
|
|
||||||
Order = 200 // B+Tree 阶数 (保守估计,叶子节点每个entry 20 bytes)
|
|
||||||
HeaderSize = 32 // 节点头大小
|
|
||||||
NodeTypeInternal = 0 // 内部节点
|
|
||||||
NodeTypeLeaf = 1 // 叶子节点
|
|
||||||
)
|
|
||||||
|
|
||||||
// BTreeNode 表示一个 B+Tree 节点 (4 KB)
|
|
||||||
type BTreeNode struct {
|
|
||||||
// Header (32 bytes)
|
|
||||||
NodeType byte // 0=Internal, 1=Leaf
|
|
||||||
KeyCount uint16 // key 数量
|
|
||||||
Level byte // 层级 (0=叶子层)
|
|
||||||
Reserved [28]byte // 预留字段
|
|
||||||
|
|
||||||
// Keys (variable, 最多 256 个)
|
|
||||||
Keys []int64 // key 数组
|
|
||||||
|
|
||||||
// Values (variable)
|
|
||||||
// Internal Node: 子节点指针
|
|
||||||
Children []int64 // 子节点的文件 offset
|
|
||||||
|
|
||||||
// Leaf Node: 数据位置
|
|
||||||
DataOffsets []int64 // 数据块的文件 offset
|
|
||||||
DataSizes []int32 // 数据块大小
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewInternalNode 创建内部节点
|
|
||||||
func NewInternalNode(level byte) *BTreeNode {
|
|
||||||
return &BTreeNode{
|
|
||||||
NodeType: NodeTypeInternal,
|
|
||||||
Level: level,
|
|
||||||
Keys: make([]int64, 0, Order),
|
|
||||||
Children: make([]int64, 0, Order+1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLeafNode 创建叶子节点
|
|
||||||
func NewLeafNode() *BTreeNode {
|
|
||||||
return &BTreeNode{
|
|
||||||
NodeType: NodeTypeLeaf,
|
|
||||||
Level: 0,
|
|
||||||
Keys: make([]int64, 0, Order),
|
|
||||||
DataOffsets: make([]int64, 0, Order),
|
|
||||||
DataSizes: make([]int32, 0, Order),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal 序列化节点到 4 KB
|
|
||||||
func (n *BTreeNode) Marshal() []byte {
|
|
||||||
buf := make([]byte, NodeSize)
|
|
||||||
|
|
||||||
// 写入 Header (32 bytes)
|
|
||||||
buf[0] = n.NodeType
|
|
||||||
binary.LittleEndian.PutUint16(buf[1:3], n.KeyCount)
|
|
||||||
buf[3] = n.Level
|
|
||||||
copy(buf[4:32], n.Reserved[:])
|
|
||||||
|
|
||||||
// 写入 Keys
|
|
||||||
offset := HeaderSize
|
|
||||||
for _, key := range n.Keys {
|
|
||||||
if offset+8 > NodeSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint64(buf[offset:offset+8], uint64(key))
|
|
||||||
offset += 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入 Values
|
|
||||||
if n.NodeType == NodeTypeInternal {
|
|
||||||
// Internal Node: 写入子节点指针
|
|
||||||
for _, child := range n.Children {
|
|
||||||
if offset+8 > NodeSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint64(buf[offset:offset+8], uint64(child))
|
|
||||||
offset += 8
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Leaf Node: 写入数据位置
|
|
||||||
for i := 0; i < len(n.Keys); i++ {
|
|
||||||
if offset+12 > NodeSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
binary.LittleEndian.PutUint64(buf[offset:offset+8], uint64(n.DataOffsets[i]))
|
|
||||||
offset += 8
|
|
||||||
binary.LittleEndian.PutUint32(buf[offset:offset+4], uint32(n.DataSizes[i]))
|
|
||||||
offset += 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmarshal 从字节数组反序列化节点
|
|
||||||
func Unmarshal(data []byte) *BTreeNode {
|
|
||||||
if len(data) < NodeSize {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
node := &BTreeNode{}
|
|
||||||
|
|
||||||
// 读取 Header
|
|
||||||
node.NodeType = data[0]
|
|
||||||
node.KeyCount = binary.LittleEndian.Uint16(data[1:3])
|
|
||||||
node.Level = data[3]
|
|
||||||
copy(node.Reserved[:], data[4:32])
|
|
||||||
|
|
||||||
// 读取 Keys
|
|
||||||
offset := HeaderSize
|
|
||||||
node.Keys = make([]int64, node.KeyCount)
|
|
||||||
for i := 0; i < int(node.KeyCount); i++ {
|
|
||||||
if offset+8 > len(data) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
node.Keys[i] = int64(binary.LittleEndian.Uint64(data[offset : offset+8]))
|
|
||||||
offset += 8
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 Values
|
|
||||||
if node.NodeType == NodeTypeInternal {
|
|
||||||
// Internal Node: 读取子节点指针
|
|
||||||
childCount := int(node.KeyCount) + 1
|
|
||||||
node.Children = make([]int64, childCount)
|
|
||||||
for i := 0; i < childCount; i++ {
|
|
||||||
if offset+8 > len(data) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
node.Children[i] = int64(binary.LittleEndian.Uint64(data[offset : offset+8]))
|
|
||||||
offset += 8
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Leaf Node: 读取数据位置
|
|
||||||
node.DataOffsets = make([]int64, node.KeyCount)
|
|
||||||
node.DataSizes = make([]int32, node.KeyCount)
|
|
||||||
for i := 0; i < int(node.KeyCount); i++ {
|
|
||||||
if offset+12 > len(data) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
node.DataOffsets[i] = int64(binary.LittleEndian.Uint64(data[offset : offset+8]))
|
|
||||||
offset += 8
|
|
||||||
node.DataSizes[i] = int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
|
||||||
offset += 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFull 检查节点是否已满
|
|
||||||
func (n *BTreeNode) IsFull() bool {
|
|
||||||
return len(n.Keys) >= Order
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddKey 添加 key (仅用于构建)
|
|
||||||
func (n *BTreeNode) AddKey(key int64) {
|
|
||||||
n.Keys = append(n.Keys, key)
|
|
||||||
n.KeyCount = uint16(len(n.Keys))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddChild 添加子节点 (仅用于内部节点)
|
|
||||||
func (n *BTreeNode) AddChild(offset int64) {
|
|
||||||
if n.NodeType != NodeTypeInternal {
|
|
||||||
panic("AddChild called on leaf node")
|
|
||||||
}
|
|
||||||
n.Children = append(n.Children, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddData 添加数据位置 (仅用于叶子节点)
|
|
||||||
func (n *BTreeNode) AddData(key int64, offset int64, size int32) {
|
|
||||||
if n.NodeType != NodeTypeLeaf {
|
|
||||||
panic("AddData called on internal node")
|
|
||||||
}
|
|
||||||
n.Keys = append(n.Keys, key)
|
|
||||||
n.DataOffsets = append(n.DataOffsets, offset)
|
|
||||||
n.DataSizes = append(n.DataSizes, size)
|
|
||||||
n.KeyCount = uint16(len(n.Keys))
|
|
||||||
}
|
|
||||||
106
btree/reader.go
106
btree/reader.go
@@ -1,106 +0,0 @@
|
|||||||
package btree
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/edsrzf/mmap-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reader 用于查询 B+Tree (mmap)
|
|
||||||
type Reader struct {
|
|
||||||
mmap mmap.MMap
|
|
||||||
rootOffset int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReader 创建查询器
|
|
||||||
func NewReader(mmap mmap.MMap, rootOffset int64) *Reader {
|
|
||||||
return &Reader{
|
|
||||||
mmap: mmap,
|
|
||||||
rootOffset: rootOffset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 查询 key,返回数据位置
|
|
||||||
func (r *Reader) Get(key int64) (dataOffset int64, dataSize int32, found bool) {
|
|
||||||
if r.rootOffset == 0 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeOffset := r.rootOffset
|
|
||||||
|
|
||||||
for {
|
|
||||||
// 读取节点 (零拷贝)
|
|
||||||
if nodeOffset+NodeSize > int64(len(r.mmap)) {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeData := r.mmap[nodeOffset : nodeOffset+NodeSize]
|
|
||||||
node := Unmarshal(nodeData)
|
|
||||||
|
|
||||||
if node == nil {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 叶子节点
|
|
||||||
if node.NodeType == NodeTypeLeaf {
|
|
||||||
// 二分查找
|
|
||||||
idx := sort.Search(len(node.Keys), func(i int) bool {
|
|
||||||
return node.Keys[i] >= key
|
|
||||||
})
|
|
||||||
if idx < len(node.Keys) && node.Keys[idx] == key {
|
|
||||||
return node.DataOffsets[idx], node.DataSizes[idx], true
|
|
||||||
}
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内部节点,继续向下
|
|
||||||
// keys[i] 是分隔符,children[i] 包含 < keys[i] 的数据
|
|
||||||
// children[i+1] 包含 >= keys[i] 的数据
|
|
||||||
idx := sort.Search(len(node.Keys), func(i int) bool {
|
|
||||||
return node.Keys[i] > key
|
|
||||||
})
|
|
||||||
// idx 现在指向第一个 > key 的位置
|
|
||||||
// 我们应该走 children[idx]
|
|
||||||
if idx >= len(node.Children) {
|
|
||||||
idx = len(node.Children) - 1
|
|
||||||
}
|
|
||||||
nodeOffset = node.Children[idx]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllKeys 获取 B+Tree 中所有的 key(按顺序)
|
|
||||||
func (r *Reader) GetAllKeys() []int64 {
|
|
||||||
if r.rootOffset == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var keys []int64
|
|
||||||
r.traverseLeafNodes(r.rootOffset, func(node *BTreeNode) {
|
|
||||||
keys = append(keys, node.Keys...)
|
|
||||||
})
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// traverseLeafNodes 遍历所有叶子节点
|
|
||||||
func (r *Reader) traverseLeafNodes(nodeOffset int64, callback func(*BTreeNode)) {
|
|
||||||
if nodeOffset+NodeSize > int64(len(r.mmap)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeData := r.mmap[nodeOffset : nodeOffset+NodeSize]
|
|
||||||
node := Unmarshal(nodeData)
|
|
||||||
|
|
||||||
if node == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if node.NodeType == NodeTypeLeaf {
|
|
||||||
// 叶子节点,执行回调
|
|
||||||
callback(node)
|
|
||||||
} else {
|
|
||||||
// 内部节点,递归遍历所有子节点
|
|
||||||
for _, childOffset := range node.Children {
|
|
||||||
r.traverseLeafNodes(childOffset, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package btree
|
package srdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -16,7 +16,7 @@ func TestBTree(t *testing.T) {
|
|||||||
defer os.Remove("test.sst")
|
defer os.Remove("test.sst")
|
||||||
|
|
||||||
// 2. 构建 B+Tree
|
// 2. 构建 B+Tree
|
||||||
builder := NewBuilder(file, 256) // 从 offset 256 开始
|
builder := NewBTreeBuilder(file, 256) // 从 offset 256 开始
|
||||||
|
|
||||||
// 添加 1000 个 key-value
|
// 添加 1000 个 key-value
|
||||||
for i := int64(1); i <= 1000; i++ {
|
for i := int64(1); i <= 1000; i++ {
|
||||||
@@ -53,7 +53,7 @@ func TestBTree(t *testing.T) {
|
|||||||
defer mmapData.Unmap()
|
defer mmapData.Unmap()
|
||||||
|
|
||||||
// 5. 查询测试
|
// 5. 查询测试
|
||||||
reader := NewReader(mmapData, rootOffset)
|
reader := NewBTreeReader(mmapData, rootOffset)
|
||||||
|
|
||||||
// 测试存在的 key
|
// 测试存在的 key
|
||||||
for i := int64(1); i <= 1000; i++ {
|
for i := int64(1); i <= 1000; i++ {
|
||||||
@@ -93,18 +93,18 @@ func TestBTreeSerialization(t *testing.T) {
|
|||||||
|
|
||||||
// 序列化
|
// 序列化
|
||||||
data := leaf.Marshal()
|
data := leaf.Marshal()
|
||||||
if len(data) != NodeSize {
|
if len(data) != BTreeNodeSize {
|
||||||
t.Errorf("Expected size %d, got %d", NodeSize, len(data))
|
t.Errorf("Expected size %d, got %d", BTreeNodeSize, len(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 反序列化
|
// 反序列化
|
||||||
leaf2 := Unmarshal(data)
|
leaf2 := UnmarshalBTree(data)
|
||||||
if leaf2 == nil {
|
if leaf2 == nil {
|
||||||
t.Fatal("Unmarshal failed")
|
t.Fatal("Unmarshal failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if leaf2.NodeType != NodeTypeLeaf {
|
if leaf2.NodeType != BTreeNodeTypeLeaf {
|
||||||
t.Error("Wrong node type")
|
t.Error("Wrong node type")
|
||||||
}
|
}
|
||||||
if leaf2.KeyCount != 3 {
|
if leaf2.KeyCount != 3 {
|
||||||
@@ -131,7 +131,7 @@ func BenchmarkBTreeGet(b *testing.B) {
|
|||||||
file, _ := os.Create("bench.sst")
|
file, _ := os.Create("bench.sst")
|
||||||
defer os.Remove("bench.sst")
|
defer os.Remove("bench.sst")
|
||||||
|
|
||||||
builder := NewBuilder(file, 256)
|
builder := NewBTreeBuilder(file, 256)
|
||||||
for i := int64(1); i <= 100000; i++ {
|
for i := int64(1); i <= 100000; i++ {
|
||||||
builder.Add(i, i*100, 100)
|
builder.Add(i, i*100, 100)
|
||||||
}
|
}
|
||||||
@@ -144,11 +144,11 @@ func BenchmarkBTreeGet(b *testing.B) {
|
|||||||
mmapData, _ := mmap.Map(file, mmap.RDONLY, 0)
|
mmapData, _ := mmap.Map(file, mmap.RDONLY, 0)
|
||||||
defer mmapData.Unmap()
|
defer mmapData.Unmap()
|
||||||
|
|
||||||
reader := NewReader(mmapData, rootOffset)
|
reader := NewBTreeReader(mmapData, rootOffset)
|
||||||
|
|
||||||
// 性能测试
|
// 性能测试
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; b.Loop(); i++ {
|
||||||
key := int64(i%100000 + 1)
|
key := int64(i%100000 + 1)
|
||||||
reader.Get(key)
|
reader.Get(key)
|
||||||
}
|
}
|
||||||
1154
compaction.go
Normal file
1154
compaction.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,370 +0,0 @@
|
|||||||
package compaction
|
|
||||||
|
|
||||||
import (
|
|
||||||
"code.tczkiot.com/srdb/manifest"
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Compactor 负责执行 Compaction
|
|
||||||
type Compactor struct {
|
|
||||||
sstDir string
|
|
||||||
picker *Picker
|
|
||||||
versionSet *manifest.VersionSet
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCompactor 创建新的 Compactor
|
|
||||||
func NewCompactor(sstDir string, versionSet *manifest.VersionSet) *Compactor {
|
|
||||||
return &Compactor{
|
|
||||||
sstDir: sstDir,
|
|
||||||
picker: NewPicker(),
|
|
||||||
versionSet: versionSet,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPicker 获取 Picker
|
|
||||||
func (c *Compactor) GetPicker() *Picker {
|
|
||||||
return c.picker
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoCompaction 执行一次 Compaction
|
|
||||||
// 返回: VersionEdit (记录变更), error
|
|
||||||
func (c *Compactor) DoCompaction(task *CompactionTask, version *manifest.Version) (*manifest.VersionEdit, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if task == nil {
|
|
||||||
return nil, fmt.Errorf("compaction task is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0. 验证输入文件是否存在(防止并发 compaction 导致的竞态)
|
|
||||||
existingInputFiles := make([]*manifest.FileMetadata, 0, len(task.InputFiles))
|
|
||||||
for _, file := range task.InputFiles {
|
|
||||||
sstPath := filepath.Join(c.sstDir, fmt.Sprintf("%06d.sst", file.FileNumber))
|
|
||||||
if _, err := os.Stat(sstPath); err == nil {
|
|
||||||
existingInputFiles = append(existingInputFiles, file)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Compaction] Warning: input file %06d.sst not found, skipping from task\n", file.FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果所有输入文件都不存在,直接返回(无需 compaction)
|
|
||||||
if len(existingInputFiles) == 0 {
|
|
||||||
fmt.Printf("[Compaction] All input files missing, compaction skipped\n")
|
|
||||||
return nil, nil // 返回 nil 表示不需要应用任何 VersionEdit
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 读取输入文件的所有行
|
|
||||||
inputRows, err := c.readInputFiles(existingInputFiles)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read input files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果输出层级有文件,需要合并重叠的文件
|
|
||||||
outputFiles := c.getOverlappingFiles(version, task.OutputLevel, inputRows)
|
|
||||||
var existingOutputFiles []*manifest.FileMetadata
|
|
||||||
var missingOutputFiles []*manifest.FileMetadata
|
|
||||||
if len(outputFiles) > 0 {
|
|
||||||
// 验证输出文件是否存在
|
|
||||||
existingOutputFiles = make([]*manifest.FileMetadata, 0, len(outputFiles))
|
|
||||||
missingOutputFiles = make([]*manifest.FileMetadata, 0)
|
|
||||||
for _, file := range outputFiles {
|
|
||||||
sstPath := filepath.Join(c.sstDir, fmt.Sprintf("%06d.sst", file.FileNumber))
|
|
||||||
if _, err := os.Stat(sstPath); err == nil {
|
|
||||||
existingOutputFiles = append(existingOutputFiles, file)
|
|
||||||
} else {
|
|
||||||
// 输出层级的文件不存在,记录并在 VersionEdit 中删除它
|
|
||||||
fmt.Printf("[Compaction] Warning: overlapping output file %06d.sst missing, will remove from MANIFEST\n", file.FileNumber)
|
|
||||||
missingOutputFiles = append(missingOutputFiles, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
outputRows, err := c.readInputFiles(existingOutputFiles)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read output files: %w", err)
|
|
||||||
}
|
|
||||||
inputRows = append(inputRows, outputRows...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 合并和去重 (保留最新的记录)
|
|
||||||
mergedRows := c.mergeRows(inputRows)
|
|
||||||
|
|
||||||
// 计算平均行大小(基于输入文件的 FileMetadata)
|
|
||||||
avgRowSize := c.calculateAvgRowSize(existingInputFiles, existingOutputFiles)
|
|
||||||
|
|
||||||
// 4. 写入新的 SST 文件到输出层级
|
|
||||||
newFiles, err := c.writeOutputFiles(mergedRows, task.OutputLevel, version, avgRowSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("write output files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 创建 VersionEdit
|
|
||||||
edit := manifest.NewVersionEdit()
|
|
||||||
|
|
||||||
// 删除实际存在且被处理的输入文件
|
|
||||||
for _, file := range existingInputFiles {
|
|
||||||
edit.DeleteFile(file.FileNumber)
|
|
||||||
}
|
|
||||||
// 删除实际存在且被处理的输出层级文件
|
|
||||||
for _, file := range existingOutputFiles {
|
|
||||||
edit.DeleteFile(file.FileNumber)
|
|
||||||
}
|
|
||||||
// 删除缺失的输出层级文件(清理 MANIFEST 中的过期引用)
|
|
||||||
for _, file := range missingOutputFiles {
|
|
||||||
edit.DeleteFile(file.FileNumber)
|
|
||||||
fmt.Printf("[Compaction] Removing missing file %06d.sst from MANIFEST\n", file.FileNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加新文件
|
|
||||||
for _, file := range newFiles {
|
|
||||||
edit.AddFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 持久化当前的文件编号计数器(关键修复:防止重启后文件编号重用)
|
|
||||||
edit.SetNextFileNumber(c.versionSet.GetNextFileNumber())
|
|
||||||
|
|
||||||
return edit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readInputFiles 读取输入文件的所有行
|
|
||||||
// 注意:调用者必须确保传入的文件都存在,否则会返回错误
|
|
||||||
func (c *Compactor) readInputFiles(files []*manifest.FileMetadata) ([]*sst.Row, error) {
|
|
||||||
var allRows []*sst.Row
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
sstPath := filepath.Join(c.sstDir, fmt.Sprintf("%06d.sst", file.FileNumber))
|
|
||||||
|
|
||||||
reader, err := sst.NewReader(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open sst %d: %w", file.FileNumber, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取文件中实际存在的所有 key(不能用 MinKey-MaxKey 范围遍历,因为 key 可能是稀疏的)
|
|
||||||
keys := reader.GetAllKeys()
|
|
||||||
for _, seq := range keys {
|
|
||||||
row, err := reader.Get(seq)
|
|
||||||
if err != nil {
|
|
||||||
// 这种情况理论上不应该发生(key 来自索引),但为了安全还是处理一下
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
allRows = append(allRows, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return allRows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOverlappingFiles 获取输出层级中与输入行重叠的文件
|
|
||||||
func (c *Compactor) getOverlappingFiles(version *manifest.Version, level int, rows []*sst.Row) []*manifest.FileMetadata {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 找到输入行的 key range
|
|
||||||
minKey := rows[0].Seq
|
|
||||||
maxKey := rows[0].Seq
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.Seq < minKey {
|
|
||||||
minKey = row.Seq
|
|
||||||
}
|
|
||||||
if row.Seq > maxKey {
|
|
||||||
maxKey = row.Seq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 找到输出层级中重叠的文件
|
|
||||||
var overlapping []*manifest.FileMetadata
|
|
||||||
levelFiles := version.GetLevel(level)
|
|
||||||
for _, file := range levelFiles {
|
|
||||||
// 检查 key range 是否重叠
|
|
||||||
if file.MaxKey >= minKey && file.MinKey <= maxKey {
|
|
||||||
overlapping = append(overlapping, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return overlapping
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeRows 合并行,去重并保留最新的记录
|
|
||||||
func (c *Compactor) mergeRows(rows []*sst.Row) []*sst.Row {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按 Seq 排序
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
return rows[i].Seq < rows[j].Seq
|
|
||||||
})
|
|
||||||
|
|
||||||
// 去重:保留相同 Seq 的最新记录 (Timestamp 最大的)
|
|
||||||
merged := make([]*sst.Row, 0, len(rows))
|
|
||||||
var lastRow *sst.Row
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
if lastRow == nil || lastRow.Seq != row.Seq {
|
|
||||||
// 新的 Seq
|
|
||||||
merged = append(merged, row)
|
|
||||||
lastRow = row
|
|
||||||
} else {
|
|
||||||
// 相同 Seq,保留 Time 更大的
|
|
||||||
if row.Time > lastRow.Time {
|
|
||||||
merged[len(merged)-1] = row
|
|
||||||
lastRow = row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateAvgRowSize 基于输入文件的 FileMetadata 计算平均行大小
|
|
||||||
func (c *Compactor) calculateAvgRowSize(inputFiles []*manifest.FileMetadata, outputFiles []*manifest.FileMetadata) int64 {
|
|
||||||
var totalSize int64
|
|
||||||
var totalRows int64
|
|
||||||
|
|
||||||
// 统计输入文件
|
|
||||||
for _, file := range inputFiles {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
totalRows += file.RowCount
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计输出文件
|
|
||||||
for _, file := range outputFiles {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
totalRows += file.RowCount
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算平均值
|
|
||||||
if totalRows == 0 {
|
|
||||||
return 1024 // 默认 1KB
|
|
||||||
}
|
|
||||||
return totalSize / totalRows
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeOutputFiles 将合并后的行写入新的 SST 文件
|
|
||||||
func (c *Compactor) writeOutputFiles(rows []*sst.Row, level int, version *manifest.Version, avgRowSize int64) ([]*manifest.FileMetadata, error) {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据层级动态调整文件大小目标
|
|
||||||
// L0: 2MB (快速 flush,小文件)
|
|
||||||
// L1: 10MB
|
|
||||||
// L2: 50MB
|
|
||||||
// L3: 100MB
|
|
||||||
// L4+: 200MB
|
|
||||||
targetFileSize := c.getTargetFileSize(level)
|
|
||||||
|
|
||||||
// 应用安全系数:由于压缩率、索引开销等因素,估算值可能不准确
|
|
||||||
// 使用 80% 的目标大小作为分割点,避免实际文件超出目标过多
|
|
||||||
targetFileSize = targetFileSize * 80 / 100
|
|
||||||
|
|
||||||
var newFiles []*manifest.FileMetadata
|
|
||||||
var currentRows []*sst.Row
|
|
||||||
var currentSize int64
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
// 使用平均行大小估算(基于输入文件的统计信息)
|
|
||||||
rowSize := avgRowSize
|
|
||||||
|
|
||||||
// 如果当前文件大小超过目标,写入文件
|
|
||||||
if currentSize > 0 && currentSize+rowSize > targetFileSize {
|
|
||||||
file, err := c.writeFile(currentRows, level, version)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
newFiles = append(newFiles, file)
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
currentRows = nil
|
|
||||||
currentSize = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
currentRows = append(currentRows, row)
|
|
||||||
currentSize += rowSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入最后一个文件
|
|
||||||
if len(currentRows) > 0 {
|
|
||||||
file, err := c.writeFile(currentRows, level, version)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
newFiles = append(newFiles, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTargetFileSize 根据层级返回目标文件大小
|
|
||||||
func (c *Compactor) getTargetFileSize(level int) int64 {
|
|
||||||
switch level {
|
|
||||||
case 0:
|
|
||||||
return 2 * 1024 * 1024 // 2MB
|
|
||||||
case 1:
|
|
||||||
return 10 * 1024 * 1024 // 10MB
|
|
||||||
case 2:
|
|
||||||
return 50 * 1024 * 1024 // 50MB
|
|
||||||
case 3:
|
|
||||||
return 100 * 1024 * 1024 // 100MB
|
|
||||||
default: // L4+
|
|
||||||
return 200 * 1024 * 1024 // 200MB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFile 写入单个 SST 文件
|
|
||||||
func (c *Compactor) writeFile(rows []*sst.Row, level int, version *manifest.Version) (*manifest.FileMetadata, error) {
|
|
||||||
// 从 VersionSet 分配新的文件编号
|
|
||||||
fileNumber := c.versionSet.AllocateFileNumber()
|
|
||||||
sstPath := filepath.Join(c.sstDir, fmt.Sprintf("%06d.sst", fileNumber))
|
|
||||||
|
|
||||||
// 创建文件
|
|
||||||
file, err := os.Create(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
writer := sst.NewWriter(file)
|
|
||||||
|
|
||||||
// 写入所有行
|
|
||||||
for _, row := range rows {
|
|
||||||
err = writer.Add(row)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(sstPath)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 完成写入
|
|
||||||
err = writer.Finish()
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(sstPath)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取文件信息
|
|
||||||
fileInfo, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 FileMetadata
|
|
||||||
metadata := &manifest.FileMetadata{
|
|
||||||
FileNumber: fileNumber,
|
|
||||||
Level: level,
|
|
||||||
FileSize: fileInfo.Size(),
|
|
||||||
MinKey: rows[0].Seq,
|
|
||||||
MaxKey: rows[len(rows)-1].Seq,
|
|
||||||
RowCount: int64(len(rows)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata, nil
|
|
||||||
}
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
package compaction
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/manifest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager 管理 Compaction 流程
|
|
||||||
type Manager struct {
|
|
||||||
compactor *Compactor
|
|
||||||
versionSet *manifest.VersionSet
|
|
||||||
sstDir string
|
|
||||||
|
|
||||||
// 控制后台 Compaction
|
|
||||||
stopCh chan struct{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
|
|
||||||
// Compaction 并发控制
|
|
||||||
compactionMu sync.Mutex // 防止并发执行 compaction
|
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
mu sync.RWMutex
|
|
||||||
totalCompactions int64
|
|
||||||
lastCompactionTime time.Time
|
|
||||||
lastFailedFile int64 // 最后失败的文件编号
|
|
||||||
consecutiveFails int // 连续失败次数
|
|
||||||
lastGCTime time.Time
|
|
||||||
totalOrphansFound int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager 创建新的 Compaction Manager
|
|
||||||
func NewManager(sstDir string, versionSet *manifest.VersionSet) *Manager {
|
|
||||||
return &Manager{
|
|
||||||
compactor: NewCompactor(sstDir, versionSet),
|
|
||||||
versionSet: versionSet,
|
|
||||||
sstDir: sstDir,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPicker 获取 Compaction Picker
|
|
||||||
func (m *Manager) GetPicker() *Picker {
|
|
||||||
return m.compactor.GetPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start 启动后台 Compaction 和垃圾回收
|
|
||||||
func (m *Manager) Start() {
|
|
||||||
m.wg.Add(2)
|
|
||||||
go m.backgroundCompaction()
|
|
||||||
go m.backgroundGarbageCollection()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop 停止后台 Compaction
|
|
||||||
func (m *Manager) Stop() {
|
|
||||||
close(m.stopCh)
|
|
||||||
m.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// backgroundCompaction 后台 Compaction 循环
|
|
||||||
func (m *Manager) backgroundCompaction() {
|
|
||||||
defer m.wg.Done()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(10 * time.Second) // 每 10 秒检查一次
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopCh:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
m.maybeCompact()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaybeCompact 检查是否需要 Compaction 并执行(公开方法,供外部调用)
|
|
||||||
// 非阻塞:如果已有 compaction 在执行,直接返回
|
|
||||||
func (m *Manager) MaybeCompact() {
|
|
||||||
// 尝试获取锁,如果已有 compaction 在执行,直接返回
|
|
||||||
if !m.compactionMu.TryLock() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer m.compactionMu.Unlock()
|
|
||||||
|
|
||||||
m.doCompact()
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeCompact 内部使用的阻塞版本(后台 goroutine 使用)
|
|
||||||
func (m *Manager) maybeCompact() {
|
|
||||||
m.compactionMu.Lock()
|
|
||||||
defer m.compactionMu.Unlock()
|
|
||||||
|
|
||||||
m.doCompact()
|
|
||||||
}
|
|
||||||
|
|
||||||
// doCompact 实际执行 compaction 的逻辑(必须在持有 compactionMu 时调用)
|
|
||||||
// 支持并发执行多个层级的 compaction
|
|
||||||
func (m *Manager) doCompact() {
|
|
||||||
// 获取当前版本
|
|
||||||
version := m.versionSet.GetCurrent()
|
|
||||||
if version == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有需要 Compaction 的任务(已按优先级排序)
|
|
||||||
picker := m.compactor.GetPicker()
|
|
||||||
tasks := picker.PickCompaction(version)
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
// 输出诊断信息
|
|
||||||
m.printCompactionStats(version, picker)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[Compaction] Found %d tasks to execute\n", len(tasks))
|
|
||||||
|
|
||||||
// 并发执行所有任务
|
|
||||||
successCount := 0
|
|
||||||
for _, task := range tasks {
|
|
||||||
// 检查是否是上次失败的文件(防止无限重试)
|
|
||||||
if len(task.InputFiles) > 0 {
|
|
||||||
firstFile := task.InputFiles[0].FileNumber
|
|
||||||
m.mu.Lock()
|
|
||||||
if m.lastFailedFile == firstFile && m.consecutiveFails >= 3 {
|
|
||||||
fmt.Printf("[Compaction] Skipping L%d file %d (failed %d times)\n",
|
|
||||||
task.Level, firstFile, m.consecutiveFails)
|
|
||||||
m.consecutiveFails = 0
|
|
||||||
m.lastFailedFile = 0
|
|
||||||
m.mu.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取最新版本(每个任务执行前)
|
|
||||||
currentVersion := m.versionSet.GetCurrent()
|
|
||||||
if currentVersion == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行 Compaction
|
|
||||||
fmt.Printf("[Compaction] Starting: L%d -> L%d, files: %d\n",
|
|
||||||
task.Level, task.OutputLevel, len(task.InputFiles))
|
|
||||||
|
|
||||||
err := m.DoCompactionWithVersion(task, currentVersion)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[Compaction] Failed L%d -> L%d: %v\n", task.Level, task.OutputLevel, err)
|
|
||||||
|
|
||||||
// 记录失败信息
|
|
||||||
if len(task.InputFiles) > 0 {
|
|
||||||
firstFile := task.InputFiles[0].FileNumber
|
|
||||||
m.mu.Lock()
|
|
||||||
if m.lastFailedFile == firstFile {
|
|
||||||
m.consecutiveFails++
|
|
||||||
} else {
|
|
||||||
m.lastFailedFile = firstFile
|
|
||||||
m.consecutiveFails = 1
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Compaction] Completed: L%d -> L%d\n", task.Level, task.OutputLevel)
|
|
||||||
successCount++
|
|
||||||
|
|
||||||
// 清除失败计数
|
|
||||||
m.mu.Lock()
|
|
||||||
m.consecutiveFails = 0
|
|
||||||
m.lastFailedFile = 0
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[Compaction] Batch completed: %d/%d tasks succeeded\n", successCount, len(tasks))
|
|
||||||
}
|
|
||||||
|
|
||||||
// printCompactionStats 输出 Compaction 统计信息(每分钟一次)
|
|
||||||
func (m *Manager) printCompactionStats(version *manifest.Version, picker *Picker) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
// 限制输出频率:每 60 秒输出一次
|
|
||||||
if time.Since(m.lastCompactionTime) < 60*time.Second {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.lastCompactionTime = time.Now()
|
|
||||||
|
|
||||||
fmt.Println("[Compaction] Status check:")
|
|
||||||
for level := 0; level < 7; level++ {
|
|
||||||
files := version.GetLevel(level)
|
|
||||||
if len(files) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, f := range files {
|
|
||||||
totalSize += f.FileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
score := picker.GetLevelScore(version, level)
|
|
||||||
fmt.Printf(" L%d: %d files, %.2f MB, score: %.2f\n",
|
|
||||||
level, len(files), float64(totalSize)/(1024*1024), score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoCompactionWithVersion 使用指定的版本执行 Compaction
|
|
||||||
func (m *Manager) DoCompactionWithVersion(task *CompactionTask, version *manifest.Version) error {
|
|
||||||
if version == nil {
|
|
||||||
return fmt.Errorf("version is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行 Compaction(使用传入的 version,而不是重新获取)
|
|
||||||
edit, err := m.compactor.DoCompaction(task, version)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("compaction failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果 edit 为 nil,说明所有文件都已经不存在,无需应用变更
|
|
||||||
if edit == nil {
|
|
||||||
fmt.Printf("[Compaction] No changes needed (files already removed)\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用 VersionEdit
|
|
||||||
err = m.versionSet.LogAndApply(edit)
|
|
||||||
if err != nil {
|
|
||||||
// LogAndApply 失败,清理已写入的新 SST 文件(防止孤儿文件)
|
|
||||||
fmt.Printf("[Compaction] LogAndApply failed, cleaning up new files: %v\n", err)
|
|
||||||
m.cleanupNewFiles(edit)
|
|
||||||
return fmt.Errorf("apply version edit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogAndApply 成功后,删除废弃的 SST 文件
|
|
||||||
m.deleteObsoleteFiles(edit)
|
|
||||||
|
|
||||||
// 更新统计信息
|
|
||||||
m.mu.Lock()
|
|
||||||
m.totalCompactions++
|
|
||||||
m.lastCompactionTime = time.Now()
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoCompaction 执行一次 Compaction(兼容旧接口)
|
|
||||||
func (m *Manager) DoCompaction(task *CompactionTask) error {
|
|
||||||
// 获取当前版本
|
|
||||||
version := m.versionSet.GetCurrent()
|
|
||||||
if version == nil {
|
|
||||||
return fmt.Errorf("no current version")
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.DoCompactionWithVersion(task, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupNewFiles 清理 LogAndApply 失败后的新文件(防止孤儿文件)
|
|
||||||
func (m *Manager) cleanupNewFiles(edit *manifest.VersionEdit) {
|
|
||||||
if edit == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[Compaction] Cleaning up %d new files after LogAndApply failure\n", len(edit.AddedFiles))
|
|
||||||
|
|
||||||
// 删除新创建的文件
|
|
||||||
for _, file := range edit.AddedFiles {
|
|
||||||
sstPath := filepath.Join(m.sstDir, fmt.Sprintf("%06d.sst", file.FileNumber))
|
|
||||||
err := os.Remove(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[Compaction] Failed to cleanup new file %06d.sst: %v\n", file.FileNumber, err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Compaction] Cleaned up new file %06d.sst\n", file.FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteObsoleteFiles 删除废弃的 SST 文件
|
|
||||||
func (m *Manager) deleteObsoleteFiles(edit *manifest.VersionEdit) {
|
|
||||||
if edit == nil {
|
|
||||||
fmt.Printf("[Compaction] deleteObsoleteFiles: edit is nil\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[Compaction] deleteObsoleteFiles: %d files to delete\n", len(edit.DeletedFiles))
|
|
||||||
|
|
||||||
// 删除被标记为删除的文件
|
|
||||||
for _, fileNum := range edit.DeletedFiles {
|
|
||||||
sstPath := filepath.Join(m.sstDir, fmt.Sprintf("%06d.sst", fileNum))
|
|
||||||
err := os.Remove(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
// 删除失败只记录日志,不影响 compaction 流程
|
|
||||||
// 后台垃圾回收器会重试
|
|
||||||
fmt.Printf("[Compaction] Failed to delete obsolete file %06d.sst: %v\n", fileNum, err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Compaction] Deleted obsolete file %06d.sst\n", fileNum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerCompaction 手动触发一次 Compaction(所有需要的层级)
|
|
||||||
func (m *Manager) TriggerCompaction() error {
|
|
||||||
version := m.versionSet.GetCurrent()
|
|
||||||
if version == nil {
|
|
||||||
return fmt.Errorf("no current version")
|
|
||||||
}
|
|
||||||
|
|
||||||
picker := m.compactor.GetPicker()
|
|
||||||
tasks := picker.PickCompaction(version)
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
return nil // 不需要 Compaction
|
|
||||||
}
|
|
||||||
|
|
||||||
// 依次执行所有任务
|
|
||||||
for _, task := range tasks {
|
|
||||||
currentVersion := m.versionSet.GetCurrent()
|
|
||||||
if err := m.DoCompactionWithVersion(task, currentVersion); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats 获取 Compaction 统计信息
|
|
||||||
func (m *Manager) GetStats() map[string]interface{} {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"total_compactions": m.totalCompactions,
|
|
||||||
"last_compaction_time": m.lastCompactionTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLevelStats 获取每层的统计信息
|
|
||||||
func (m *Manager) GetLevelStats() []map[string]interface{} {
|
|
||||||
version := m.versionSet.GetCurrent()
|
|
||||||
if version == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
picker := m.compactor.GetPicker()
|
|
||||||
stats := make([]map[string]interface{}, manifest.NumLevels)
|
|
||||||
|
|
||||||
for level := 0; level < manifest.NumLevels; level++ {
|
|
||||||
files := version.GetLevel(level)
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range files {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
stats[level] = map[string]interface{}{
|
|
||||||
"level": level,
|
|
||||||
"file_count": len(files),
|
|
||||||
"total_size": totalSize,
|
|
||||||
"score": picker.GetLevelScore(version, level),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// backgroundGarbageCollection 后台垃圾回收循环
|
|
||||||
func (m *Manager) backgroundGarbageCollection() {
|
|
||||||
defer m.wg.Done()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(5 * time.Minute) // 每 5 分钟检查一次
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopCh:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
m.collectOrphanFiles()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectOrphanFiles 收集并删除孤儿 SST 文件
|
|
||||||
func (m *Manager) collectOrphanFiles() {
|
|
||||||
// 1. 获取当前版本中的所有活跃文件
|
|
||||||
version := m.versionSet.GetCurrent()
|
|
||||||
if version == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activeFiles := make(map[int64]bool)
|
|
||||||
for level := 0; level < manifest.NumLevels; level++ {
|
|
||||||
files := version.GetLevel(level)
|
|
||||||
for _, file := range files {
|
|
||||||
activeFiles[file.FileNumber] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 扫描 SST 目录中的所有文件
|
|
||||||
pattern := filepath.Join(m.sstDir, "*.sst")
|
|
||||||
sstFiles, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[GC] Failed to scan SST directory: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 找出孤儿文件并删除
|
|
||||||
orphanCount := 0
|
|
||||||
for _, sstPath := range sstFiles {
|
|
||||||
// 提取文件编号
|
|
||||||
var fileNum int64
|
|
||||||
_, err := fmt.Sscanf(filepath.Base(sstPath), "%d.sst", &fileNum)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是活跃文件
|
|
||||||
if !activeFiles[fileNum] {
|
|
||||||
// 这是孤儿文件,删除它
|
|
||||||
err := os.Remove(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[GC] Failed to delete orphan file %06d.sst: %v\n", fileNum, err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[GC] Deleted orphan file %06d.sst\n", fileNum)
|
|
||||||
orphanCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 更新统计信息
|
|
||||||
m.mu.Lock()
|
|
||||||
m.lastGCTime = time.Now()
|
|
||||||
m.totalOrphansFound += int64(orphanCount)
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if orphanCount > 0 {
|
|
||||||
fmt.Printf("[GC] Completed: cleaned up %d orphan files (total: %d)\n", orphanCount, m.totalOrphansFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupOrphanFiles 手动触发孤儿文件清理(可在启动时调用)
|
|
||||||
func (m *Manager) CleanupOrphanFiles() {
|
|
||||||
fmt.Println("[GC] Manual cleanup triggered")
|
|
||||||
m.collectOrphanFiles()
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
package compaction
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/manifest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompactionTask 表示一个 Compaction 任务
|
|
||||||
type CompactionTask struct {
|
|
||||||
Level int // 源层级
|
|
||||||
InputFiles []*manifest.FileMetadata // 需要合并的输入文件
|
|
||||||
OutputLevel int // 输出层级
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picker 负责选择需要 Compaction 的文件
|
|
||||||
type Picker struct {
|
|
||||||
// Level 大小限制 (字节)
|
|
||||||
levelSizeLimits [manifest.NumLevels]int64
|
|
||||||
|
|
||||||
// Level 文件数量限制
|
|
||||||
levelFileLimits [manifest.NumLevels]int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPicker 创建新的 Compaction Picker
|
|
||||||
func NewPicker() *Picker {
|
|
||||||
p := &Picker{}
|
|
||||||
|
|
||||||
// 设置每层的大小限制 (指数增长)
|
|
||||||
// L0: 10MB, L1: 100MB, L2: 1GB, L3: 10GB, L4: 100GB, L5: 1TB, L6: 无限制
|
|
||||||
p.levelSizeLimits[0] = 10 * 1024 * 1024 // 10MB
|
|
||||||
p.levelSizeLimits[1] = 100 * 1024 * 1024 // 100MB
|
|
||||||
p.levelSizeLimits[2] = 1024 * 1024 * 1024 // 1GB
|
|
||||||
p.levelSizeLimits[3] = 10 * 1024 * 1024 * 1024 // 10GB
|
|
||||||
p.levelSizeLimits[4] = 100 * 1024 * 1024 * 1024 // 100GB
|
|
||||||
p.levelSizeLimits[5] = 1024 * 1024 * 1024 * 1024 // 1TB
|
|
||||||
p.levelSizeLimits[6] = 0 // 无限制
|
|
||||||
|
|
||||||
// 设置每层的文件数量限制
|
|
||||||
// L0 特殊处理:文件数量限制为 4 (当有4个或更多文件时触发 compaction)
|
|
||||||
p.levelFileLimits[0] = 4
|
|
||||||
// L1-L6: 不限制文件数量,只限制总大小
|
|
||||||
for i := 1; i < manifest.NumLevels; i++ {
|
|
||||||
p.levelFileLimits[i] = 0 // 0 表示不限制
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// PickCompaction 选择需要 Compaction 的任务(支持多任务并发)
|
|
||||||
// 返回空切片表示当前不需要 Compaction
|
|
||||||
func (p *Picker) PickCompaction(version *manifest.Version) []*CompactionTask {
|
|
||||||
tasks := make([]*CompactionTask, 0)
|
|
||||||
|
|
||||||
// 1. 检查 L0 (基于文件数量)
|
|
||||||
if task := p.pickL0Compaction(version); task != nil {
|
|
||||||
tasks = append(tasks, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查 L1-L5 (基于大小)
|
|
||||||
for level := 1; level < manifest.NumLevels-1; level++ {
|
|
||||||
if task := p.pickLevelCompaction(version, level); task != nil {
|
|
||||||
tasks = append(tasks, task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 按优先级排序(score 越高越优先)
|
|
||||||
if len(tasks) > 1 {
|
|
||||||
p.sortTasksByPriority(tasks, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortTasksByPriority 按优先级对任务排序(score 从高到低)
|
|
||||||
func (p *Picker) sortTasksByPriority(tasks []*CompactionTask, version *manifest.Version) {
|
|
||||||
// 简单的冒泡排序(任务数量通常很少,< 7)
|
|
||||||
for i := 0; i < len(tasks)-1; i++ {
|
|
||||||
for j := i + 1; j < len(tasks); j++ {
|
|
||||||
scoreI := p.GetLevelScore(version, tasks[i].Level)
|
|
||||||
scoreJ := p.GetLevelScore(version, tasks[j].Level)
|
|
||||||
if scoreJ > scoreI {
|
|
||||||
tasks[i], tasks[j] = tasks[j], tasks[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickL0Compaction 选择 L0 的 Compaction 任务
|
|
||||||
// L0 特殊:文件可能有重叠的 key range,需要全部合并
|
|
||||||
func (p *Picker) pickL0Compaction(version *manifest.Version) *CompactionTask {
|
|
||||||
l0Files := version.GetLevel(0)
|
|
||||||
if len(l0Files) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算 L0 总大小
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range l0Files {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否需要 Compaction(同时考虑文件数量和总大小)
|
|
||||||
// 1. 文件数量超过限制(避免读放大:每次读取需要检查太多文件)
|
|
||||||
// 2. 总大小超过限制(避免 L0 占用过多空间)
|
|
||||||
needCompaction := false
|
|
||||||
if p.levelFileLimits[0] > 0 && len(l0Files) >= p.levelFileLimits[0] {
|
|
||||||
needCompaction = true
|
|
||||||
}
|
|
||||||
if p.levelSizeLimits[0] > 0 && totalSize >= p.levelSizeLimits[0] {
|
|
||||||
needCompaction = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !needCompaction {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// L0 → L1 Compaction
|
|
||||||
// 选择所有 L0 文件(因为 key range 可能重叠)
|
|
||||||
return &CompactionTask{
|
|
||||||
Level: 0,
|
|
||||||
InputFiles: l0Files,
|
|
||||||
OutputLevel: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickLevelCompaction 选择 L1-L5 的 Compaction 任务
|
|
||||||
// L1+ 的文件 key range 不重叠,可以选择多个不重叠的文件
|
|
||||||
func (p *Picker) pickLevelCompaction(version *manifest.Version, level int) *CompactionTask {
|
|
||||||
if level < 1 || level >= manifest.NumLevels-1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
files := version.GetLevel(level)
|
|
||||||
if len(files) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算当前层级的总大小
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range files {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否超过大小限制
|
|
||||||
if totalSize < p.levelSizeLimits[level] {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 改进策略:根据层级压力动态调整选择策略
|
|
||||||
// 1. 计算当前层级的压力(超过限制的倍数)
|
|
||||||
pressure := float64(totalSize) / float64(p.levelSizeLimits[level])
|
|
||||||
|
|
||||||
// 2. 根据压力确定目标大小和文件数量限制
|
|
||||||
targetSize := p.getTargetCompactionSize(level + 1)
|
|
||||||
maxFiles := 10 // 默认最多 10 个文件
|
|
||||||
|
|
||||||
if pressure >= 10.0 {
|
|
||||||
// 压力极高(超过 10 倍):选择更多文件,增大目标
|
|
||||||
maxFiles = 100
|
|
||||||
targetSize *= 5
|
|
||||||
fmt.Printf("[Compaction] L%d pressure: %.1fx (CRITICAL) - selecting up to %d files, target: %s\n",
|
|
||||||
level, pressure, maxFiles, formatBytes(targetSize))
|
|
||||||
} else if pressure >= 5.0 {
|
|
||||||
// 压力很高(超过 5 倍)
|
|
||||||
maxFiles = 50
|
|
||||||
targetSize *= 3
|
|
||||||
fmt.Printf("[Compaction] L%d pressure: %.1fx (HIGH) - selecting up to %d files, target: %s\n",
|
|
||||||
level, pressure, maxFiles, formatBytes(targetSize))
|
|
||||||
} else if pressure >= 2.0 {
|
|
||||||
// 压力较高(超过 2 倍)
|
|
||||||
maxFiles = 20
|
|
||||||
targetSize *= 2
|
|
||||||
fmt.Printf("[Compaction] L%d pressure: %.1fx (ELEVATED) - selecting up to %d files, target: %s\n",
|
|
||||||
level, pressure, maxFiles, formatBytes(targetSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择文件,直到累计大小接近目标
|
|
||||||
selectedFiles := make([]*manifest.FileMetadata, 0)
|
|
||||||
currentSize := int64(0)
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
selectedFiles = append(selectedFiles, file)
|
|
||||||
currentSize += file.FileSize
|
|
||||||
|
|
||||||
// 如果已经达到目标大小,停止选择
|
|
||||||
if currentSize >= targetSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 达到文件数量限制
|
|
||||||
if len(selectedFiles) >= maxFiles {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CompactionTask{
|
|
||||||
Level: level,
|
|
||||||
InputFiles: selectedFiles,
|
|
||||||
OutputLevel: level + 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTargetCompactionSize 根据层级返回建议的 compaction 大小
|
|
||||||
func (p *Picker) getTargetCompactionSize(level int) int64 {
|
|
||||||
switch level {
|
|
||||||
case 0:
|
|
||||||
return 2 * 1024 * 1024 // 2MB
|
|
||||||
case 1:
|
|
||||||
return 10 * 1024 * 1024 // 10MB
|
|
||||||
case 2:
|
|
||||||
return 50 * 1024 * 1024 // 50MB
|
|
||||||
case 3:
|
|
||||||
return 100 * 1024 * 1024 // 100MB
|
|
||||||
default: // L4+
|
|
||||||
return 200 * 1024 * 1024 // 200MB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShouldCompact 判断是否需要 Compaction
|
|
||||||
func (p *Picker) ShouldCompact(version *manifest.Version) bool {
|
|
||||||
tasks := p.PickCompaction(version)
|
|
||||||
return len(tasks) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLevelScore 获取每层的 Compaction 得分 (用于优先级排序)
|
|
||||||
// 得分越高,越需要 Compaction
|
|
||||||
func (p *Picker) GetLevelScore(version *manifest.Version, level int) float64 {
|
|
||||||
if level < 0 || level >= manifest.NumLevels {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
files := version.GetLevel(level)
|
|
||||||
|
|
||||||
// L0 同时考虑文件数量和总大小,取较大值作为得分
|
|
||||||
if level == 0 {
|
|
||||||
scoreByCount := float64(0)
|
|
||||||
scoreBySize := float64(0)
|
|
||||||
|
|
||||||
if p.levelFileLimits[0] > 0 {
|
|
||||||
scoreByCount = float64(len(files)) / float64(p.levelFileLimits[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.levelSizeLimits[0] > 0 {
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range files {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
}
|
|
||||||
scoreBySize = float64(totalSize) / float64(p.levelSizeLimits[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回两者中的较大值(哪个维度更紧迫)
|
|
||||||
if scoreByCount > scoreBySize {
|
|
||||||
return scoreByCount
|
|
||||||
}
|
|
||||||
return scoreBySize
|
|
||||||
}
|
|
||||||
|
|
||||||
// L1+ 基于总大小
|
|
||||||
if p.levelSizeLimits[level] == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range files {
|
|
||||||
totalSize += file.FileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return float64(totalSize) / float64(p.levelSizeLimits[level])
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBytes 格式化字节大小显示
|
|
||||||
func formatBytes(bytes int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if bytes < unit {
|
|
||||||
return fmt.Sprintf("%d B", bytes)
|
|
||||||
}
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := bytes / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
units := []string{"KB", "MB", "GB", "TB"}
|
|
||||||
return fmt.Sprintf("%.2f %s", float64(bytes)/float64(div), units[exp])
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
package compaction
|
package srdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"code.tczkiot.com/srdb/manifest"
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompactionBasic(t *testing.T) {
|
func TestCompactionBasic(t *testing.T) {
|
||||||
@@ -21,26 +20,26 @@ func TestCompactionBasic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建 VersionSet
|
// 创建 VersionSet
|
||||||
versionSet, err := manifest.NewVersionSet(manifestDir)
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer versionSet.Close()
|
defer versionSet.Close()
|
||||||
|
|
||||||
// 创建 SST Manager
|
// 创建 SST Manager
|
||||||
sstMgr, err := sst.NewManager(sstDir)
|
sstMgr, err := NewSSTableManager(sstDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer sstMgr.Close()
|
defer sstMgr.Close()
|
||||||
|
|
||||||
// 创建测试数据
|
// 创建测试数据
|
||||||
rows1 := make([]*sst.Row, 100)
|
rows1 := make([]*SSTableRow, 100)
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
rows1[i] = &sst.Row{
|
rows1[i] = &SSTableRow{
|
||||||
Seq: int64(i),
|
Seq: int64(i),
|
||||||
Time: 1000,
|
Time: 1000,
|
||||||
Data: map[string]interface{}{"value": i},
|
Data: map[string]any{"value": i},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +50,8 @@ func TestCompactionBasic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加到 Version
|
// 添加到 Version
|
||||||
edit1 := manifest.NewVersionEdit()
|
edit1 := NewVersionEdit()
|
||||||
edit1.AddFile(&manifest.FileMetadata{
|
edit1.AddFile(&FileMetadata{
|
||||||
FileNumber: 1,
|
FileNumber: 1,
|
||||||
Level: 0,
|
Level: 0,
|
||||||
FileSize: 1024,
|
FileSize: 1024,
|
||||||
@@ -75,16 +74,16 @@ func TestCompactionBasic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建 Compaction Manager
|
// 创建 Compaction Manager
|
||||||
compactionMgr := NewManager(sstDir, versionSet)
|
compactionMgr := NewCompactionManager(sstDir, versionSet, sstMgr)
|
||||||
|
|
||||||
// 创建更多文件触发 Compaction
|
// 创建更多文件触发 Compaction
|
||||||
for i := 1; i < 5; i++ {
|
for i := 1; i < 5; i++ {
|
||||||
rows := make([]*sst.Row, 50)
|
rows := make([]*SSTableRow, 50)
|
||||||
for j := 0; j < 50; j++ {
|
for j := range 50 {
|
||||||
rows[j] = &sst.Row{
|
rows[j] = &SSTableRow{
|
||||||
Seq: int64(i*100 + j),
|
Seq: int64(i*100 + j),
|
||||||
Time: int64(1000 + i),
|
Time: int64(1000 + i),
|
||||||
Data: map[string]interface{}{"value": i*100 + j},
|
Data: map[string]any{"value": i*100 + j},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +92,8 @@ func TestCompactionBasic(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
edit := manifest.NewVersionEdit()
|
edit := NewVersionEdit()
|
||||||
edit.AddFile(&manifest.FileMetadata{
|
edit.AddFile(&FileMetadata{
|
||||||
FileNumber: int64(i + 1),
|
FileNumber: int64(i + 1),
|
||||||
Level: 0,
|
Level: 0,
|
||||||
FileSize: 512,
|
FileSize: 512,
|
||||||
@@ -152,7 +151,7 @@ func TestPickerLevelScore(t *testing.T) {
|
|||||||
manifestDir := tmpDir
|
manifestDir := tmpDir
|
||||||
|
|
||||||
// 创建 VersionSet
|
// 创建 VersionSet
|
||||||
versionSet, err := manifest.NewVersionSet(manifestDir)
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -162,9 +161,9 @@ func TestPickerLevelScore(t *testing.T) {
|
|||||||
picker := NewPicker()
|
picker := NewPicker()
|
||||||
|
|
||||||
// 添加一些文件到 L0
|
// 添加一些文件到 L0
|
||||||
edit := manifest.NewVersionEdit()
|
edit := NewVersionEdit()
|
||||||
for i := 0; i < 3; i++ {
|
for i := range 3 {
|
||||||
edit.AddFile(&manifest.FileMetadata{
|
edit.AddFile(&FileMetadata{
|
||||||
FileNumber: int64(i + 1),
|
FileNumber: int64(i + 1),
|
||||||
Level: 0,
|
Level: 0,
|
||||||
FileSize: 1024 * 1024, // 1MB
|
FileSize: 1024 * 1024, // 1MB
|
||||||
@@ -206,28 +205,28 @@ func TestCompactionMerge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建 VersionSet
|
// 创建 VersionSet
|
||||||
versionSet, err := manifest.NewVersionSet(manifestDir)
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer versionSet.Close()
|
defer versionSet.Close()
|
||||||
|
|
||||||
// 创建 SST Manager
|
// 创建 SST Manager
|
||||||
sstMgr, err := sst.NewManager(sstDir)
|
sstMgr, err := NewSSTableManager(sstDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer sstMgr.Close()
|
defer sstMgr.Close()
|
||||||
|
|
||||||
// 创建两个有重叠 key 的 SST 文件
|
// 创建两个有重叠 key 的 SST 文件
|
||||||
rows1 := []*sst.Row{
|
rows1 := []*SSTableRow{
|
||||||
{Seq: 1, Time: 1000, Data: map[string]interface{}{"value": "old"}},
|
{Seq: 1, Time: 1000, Data: map[string]any{"value": "old"}},
|
||||||
{Seq: 2, Time: 1000, Data: map[string]interface{}{"value": "old"}},
|
{Seq: 2, Time: 1000, Data: map[string]any{"value": "old"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
rows2 := []*sst.Row{
|
rows2 := []*SSTableRow{
|
||||||
{Seq: 1, Time: 2000, Data: map[string]interface{}{"value": "new"}}, // 更新
|
{Seq: 1, Time: 2000, Data: map[string]any{"value": "new"}}, // 更新
|
||||||
{Seq: 3, Time: 2000, Data: map[string]interface{}{"value": "new"}},
|
{Seq: 3, Time: 2000, Data: map[string]any{"value": "new"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
reader1, err := sstMgr.CreateSST(1, rows1)
|
reader1, err := sstMgr.CreateSST(1, rows1)
|
||||||
@@ -243,8 +242,8 @@ func TestCompactionMerge(t *testing.T) {
|
|||||||
defer reader2.Close()
|
defer reader2.Close()
|
||||||
|
|
||||||
// 添加到 Version
|
// 添加到 Version
|
||||||
edit := manifest.NewVersionEdit()
|
edit := NewVersionEdit()
|
||||||
edit.AddFile(&manifest.FileMetadata{
|
edit.AddFile(&FileMetadata{
|
||||||
FileNumber: 1,
|
FileNumber: 1,
|
||||||
Level: 0,
|
Level: 0,
|
||||||
FileSize: 512,
|
FileSize: 512,
|
||||||
@@ -252,7 +251,7 @@ func TestCompactionMerge(t *testing.T) {
|
|||||||
MaxKey: 2,
|
MaxKey: 2,
|
||||||
RowCount: 2,
|
RowCount: 2,
|
||||||
})
|
})
|
||||||
edit.AddFile(&manifest.FileMetadata{
|
edit.AddFile(&FileMetadata{
|
||||||
FileNumber: 2,
|
FileNumber: 2,
|
||||||
Level: 0,
|
Level: 0,
|
||||||
FileSize: 512,
|
FileSize: 512,
|
||||||
@@ -318,14 +317,14 @@ func BenchmarkCompaction(b *testing.B) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建 VersionSet
|
// 创建 VersionSet
|
||||||
versionSet, err := manifest.NewVersionSet(manifestDir)
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer versionSet.Close()
|
defer versionSet.Close()
|
||||||
|
|
||||||
// 创建 SST Manager
|
// 创建 SST Manager
|
||||||
sstMgr, err := sst.NewManager(sstDir)
|
sstMgr, err := NewSSTableManager(sstDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -335,13 +334,13 @@ func BenchmarkCompaction(b *testing.B) {
|
|||||||
const numFiles = 5
|
const numFiles = 5
|
||||||
const rowsPerFile = 1000
|
const rowsPerFile = 1000
|
||||||
|
|
||||||
for i := 0; i < numFiles; i++ {
|
for i := range numFiles {
|
||||||
rows := make([]*sst.Row, rowsPerFile)
|
rows := make([]*SSTableRow, rowsPerFile)
|
||||||
for j := 0; j < rowsPerFile; j++ {
|
for j := range rowsPerFile {
|
||||||
rows[j] = &sst.Row{
|
rows[j] = &SSTableRow{
|
||||||
Seq: int64(i*rowsPerFile + j),
|
Seq: int64(i*rowsPerFile + j),
|
||||||
Time: int64(1000 + i),
|
Time: int64(1000 + i),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]any{
|
||||||
"value": fmt.Sprintf("data-%d-%d", i, j),
|
"value": fmt.Sprintf("data-%d-%d", i, j),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -353,8 +352,8 @@ func BenchmarkCompaction(b *testing.B) {
|
|||||||
}
|
}
|
||||||
reader.Close()
|
reader.Close()
|
||||||
|
|
||||||
edit := manifest.NewVersionEdit()
|
edit := NewVersionEdit()
|
||||||
edit.AddFile(&manifest.FileMetadata{
|
edit.AddFile(&FileMetadata{
|
||||||
FileNumber: int64(i + 1),
|
FileNumber: int64(i + 1),
|
||||||
Level: 0,
|
Level: 0,
|
||||||
FileSize: 10240,
|
FileSize: 10240,
|
||||||
@@ -381,12 +380,148 @@ func BenchmarkCompaction(b *testing.B) {
|
|||||||
OutputLevel: 1,
|
OutputLevel: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ResetTimer()
|
for b.Loop() {
|
||||||
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
_, err := compactor.DoCompaction(task, version)
|
_, err := compactor.DoCompaction(task, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCompactionQueryOrder 测试 compaction 后查询结果的排序
|
||||||
|
func TestCompactionQueryOrder(t *testing.T) {
|
||||||
|
// 创建临时目录
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 创建 Schema - 包含多个字段以增加数据大小
|
||||||
|
schema := NewSchema("test", []Field{
|
||||||
|
{Name: "id", Type: FieldTypeInt64},
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "data", Type: FieldTypeString},
|
||||||
|
{Name: "timestamp", Type: FieldTypeInt64},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开 Engine (使用较小的 MemTable 触发频繁 flush)
|
||||||
|
engine, err := OpenEngine(&EngineOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
MemTableSize: 2 * 1024 * 1024, // 2MB MemTable
|
||||||
|
Schema: schema,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer engine.Close()
|
||||||
|
|
||||||
|
t.Logf("开始插入 4000 条数据...")
|
||||||
|
|
||||||
|
// 插入 4000 条数据,每条数据大小在 2KB-1MB 之间
|
||||||
|
for i := range 4000 {
|
||||||
|
// 生成 2KB 到 1MB 的随机数据
|
||||||
|
dataSize := 2*1024 + (i % (1024*1024 - 2*1024)) // 2KB ~ 1MB
|
||||||
|
largeData := make([]byte, dataSize)
|
||||||
|
for j := range largeData {
|
||||||
|
largeData[j] = byte('A' + (j % 26))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := engine.Insert(map[string]any{
|
||||||
|
"id": int64(i),
|
||||||
|
"name": fmt.Sprintf("user_%d", i),
|
||||||
|
"data": string(largeData),
|
||||||
|
"timestamp": int64(1000000 + i),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i+1)%500 == 0 {
|
||||||
|
t.Logf("已插入 %d 条数据", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("插入完成,等待后台 compaction...")
|
||||||
|
|
||||||
|
// 等待一段时间让后台 compaction 有机会运行
|
||||||
|
// 后台 compaction 每 10 秒检查一次,所以需要等待至少 12 秒
|
||||||
|
time.Sleep(12 * time.Second)
|
||||||
|
|
||||||
|
t.Logf("开始查询所有数据...")
|
||||||
|
|
||||||
|
// 查询所有数据
|
||||||
|
rows, err := engine.Query().Rows()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 验证顺序和数据完整性
|
||||||
|
var lastSeq int64 = 0
|
||||||
|
count := 0
|
||||||
|
expectedIDs := make(map[int64]bool) // 用于验证所有 ID 都存在
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
row := rows.Row()
|
||||||
|
data := row.Data()
|
||||||
|
currentSeq := data["_seq"].(int64)
|
||||||
|
|
||||||
|
// 验证顺序
|
||||||
|
if currentSeq <= lastSeq {
|
||||||
|
t.Errorf("Query results NOT in order: got seq %d after seq %d", currentSeq, lastSeq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据完整性
|
||||||
|
id, ok := data["id"].(int64)
|
||||||
|
if !ok {
|
||||||
|
// 尝试其他类型
|
||||||
|
if idFloat, ok2 := data["id"].(float64); ok2 {
|
||||||
|
id = int64(idFloat)
|
||||||
|
expectedIDs[id] = true
|
||||||
|
} else {
|
||||||
|
t.Errorf("Seq %d: missing or invalid id field, actual type: %T, value: %v",
|
||||||
|
currentSeq, data["id"], data["id"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expectedIDs[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 name 字段
|
||||||
|
name, ok := data["name"].(string)
|
||||||
|
if !ok || name != fmt.Sprintf("user_%d", id) {
|
||||||
|
t.Errorf("Seq %d: invalid name field, expected 'user_%d', got '%v'", currentSeq, id, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 data 字段存在且不为空
|
||||||
|
dataStr, ok := data["data"].(string)
|
||||||
|
if !ok || len(dataStr) < 2*1024 {
|
||||||
|
t.Errorf("Seq %d: invalid data field size", currentSeq)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeq = currentSeq
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 4000 {
|
||||||
|
t.Errorf("Expected 4000 rows, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有 ID 都存在
|
||||||
|
for i := range int64(4000) {
|
||||||
|
if !expectedIDs[i] {
|
||||||
|
t.Errorf("Missing ID: %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ 查询返回 %d 条记录,顺序正确 (seq 1→%d)", count, lastSeq)
|
||||||
|
t.Logf("✓ 所有数据完整性验证通过")
|
||||||
|
|
||||||
|
// 输出 compaction 统计信息
|
||||||
|
stats := engine.GetCompactionManager().GetLevelStats()
|
||||||
|
t.Logf("Compaction 统计:")
|
||||||
|
for _, levelStat := range stats {
|
||||||
|
level := levelStat["level"].(int)
|
||||||
|
fileCount := levelStat["file_count"].(int)
|
||||||
|
totalSize := levelStat["total_size"].(int64)
|
||||||
|
if fileCount > 0 {
|
||||||
|
t.Logf(" L%d: %d 个文件, %.2f MB", level, fileCount, float64(totalSize)/(1024*1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package srdb
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -250,8 +251,6 @@ func (db *Database) GetAllTablesInfo() map[string]*Table {
|
|||||||
|
|
||||||
// 返回副本以避免并发问题
|
// 返回副本以避免并发问题
|
||||||
result := make(map[string]*Table, len(db.tables))
|
result := make(map[string]*Table, len(db.tables))
|
||||||
for k, v := range db.tables {
|
maps.Copy(result, db.tables)
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
131
engine.go
131
engine.go
@@ -9,12 +9,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/compaction"
|
|
||||||
"code.tczkiot.com/srdb/manifest"
|
|
||||||
"code.tczkiot.com/srdb/memtable"
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
|
||||||
"code.tczkiot.com/srdb/wal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,11 +20,11 @@ type Engine struct {
|
|||||||
dir string
|
dir string
|
||||||
schema *Schema
|
schema *Schema
|
||||||
indexManager *IndexManager
|
indexManager *IndexManager
|
||||||
walManager *wal.Manager // WAL 管理器
|
walManager *WALManager // WAL 管理器
|
||||||
sstManager *sst.Manager // SST 管理器
|
sstManager *SSTableManager // SST 管理器
|
||||||
memtableManager *memtable.Manager // MemTable 管理器
|
memtableManager *MemTableManager // MemTable 管理器
|
||||||
versionSet *manifest.VersionSet // MANIFEST 管理器
|
versionSet *VersionSet // MANIFEST 管理器
|
||||||
compactionManager *compaction.Manager // Compaction 管理器
|
compactionManager *CompactionManager // Compaction 管理器
|
||||||
seq atomic.Int64
|
seq atomic.Int64
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
flushMu sync.Mutex
|
flushMu sync.Mutex
|
||||||
@@ -125,17 +119,22 @@ func OpenEngine(opts *EngineOptions) (*Engine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建 SST Manager
|
// 创建 SST Manager
|
||||||
sstMgr, err := sst.NewManager(sstDir)
|
sstMgr, err := NewSSTableManager(sstDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置 Schema(用于优化编解码)
|
||||||
|
if sch != nil {
|
||||||
|
sstMgr.SetSchema(sch)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建 MemTable Manager
|
// 创建 MemTable Manager
|
||||||
memMgr := memtable.NewManager(opts.MemTableSize)
|
memMgr := NewMemTableManager(opts.MemTableSize)
|
||||||
|
|
||||||
// 创建/恢复 MANIFEST
|
// 创建/恢复 MANIFEST
|
||||||
manifestDir := opts.Dir
|
manifestDir := opts.Dir
|
||||||
versionSet, err := manifest.NewVersionSet(manifestDir)
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create version set: %w", err)
|
return nil, fmt.Errorf("create version set: %w", err)
|
||||||
}
|
}
|
||||||
@@ -158,7 +157,7 @@ func OpenEngine(opts *EngineOptions) (*Engine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 恢复完成后,创建 WAL Manager 用于后续写入
|
// 恢复完成后,创建 WAL Manager 用于后续写入
|
||||||
walMgr, err := wal.NewManager(walDir)
|
walMgr, err := NewWALManager(walDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -166,7 +165,12 @@ func OpenEngine(opts *EngineOptions) (*Engine, error) {
|
|||||||
engine.memtableManager.SetActiveWAL(walMgr.GetCurrentNumber())
|
engine.memtableManager.SetActiveWAL(walMgr.GetCurrentNumber())
|
||||||
|
|
||||||
// 创建 Compaction Manager
|
// 创建 Compaction Manager
|
||||||
engine.compactionManager = compaction.NewManager(sstDir, versionSet)
|
engine.compactionManager = NewCompactionManager(sstDir, versionSet, sstMgr)
|
||||||
|
|
||||||
|
// 设置 Schema(如果有)
|
||||||
|
if sch != nil {
|
||||||
|
engine.compactionManager.SetSchema(sch)
|
||||||
|
}
|
||||||
|
|
||||||
// 启动时清理孤儿文件(崩溃恢复后的清理)
|
// 启动时清理孤儿文件(崩溃恢复后的清理)
|
||||||
engine.compactionManager.CleanupOrphanFiles()
|
engine.compactionManager.CleanupOrphanFiles()
|
||||||
@@ -195,7 +199,7 @@ func (e *Engine) Insert(data map[string]any) error {
|
|||||||
seq := e.seq.Add(1)
|
seq := e.seq.Add(1)
|
||||||
|
|
||||||
// 2. 添加系统字段
|
// 2. 添加系统字段
|
||||||
row := &sst.Row{
|
row := &SSTableRow{
|
||||||
Seq: seq,
|
Seq: seq,
|
||||||
Time: time.Now().UnixNano(),
|
Time: time.Now().UnixNano(),
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -208,8 +212,8 @@ func (e *Engine) Insert(data map[string]any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 写入 WAL
|
// 4. 写入 WAL
|
||||||
entry := &wal.Entry{
|
entry := &WALEntry{
|
||||||
Type: wal.EntryTypePut,
|
Type: WALEntryTypePut,
|
||||||
Seq: seq,
|
Seq: seq,
|
||||||
Data: rowData,
|
Data: rowData,
|
||||||
}
|
}
|
||||||
@@ -235,11 +239,11 @@ func (e *Engine) Insert(data map[string]any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get 查询数据
|
// Get 查询数据
|
||||||
func (e *Engine) Get(seq int64) (*sst.Row, error) {
|
func (e *Engine) Get(seq int64) (*SSTableRow, error) {
|
||||||
// 1. 先查 MemTable Manager (Active + Immutables)
|
// 1. 先查 MemTable Manager (Active + Immutables)
|
||||||
data, found := e.memtableManager.Get(seq)
|
data, found := e.memtableManager.Get(seq)
|
||||||
if found {
|
if found {
|
||||||
var row sst.Row
|
var row SSTableRow
|
||||||
err := json.Unmarshal(data, &row)
|
err := json.Unmarshal(data, &row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -251,6 +255,35 @@ func (e *Engine) Get(seq int64) (*sst.Row, error) {
|
|||||||
return e.sstManager.Get(seq)
|
return e.sstManager.Get(seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPartial 按需查询数据(只读取指定字段)
|
||||||
|
func (e *Engine) GetPartial(seq int64, fields []string) (*SSTableRow, error) {
|
||||||
|
// 1. 先查 MemTable Manager (Active + Immutables)
|
||||||
|
data, found := e.memtableManager.Get(seq)
|
||||||
|
if found {
|
||||||
|
var row SSTableRow
|
||||||
|
err := json.Unmarshal(data, &row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemTable 中的数据已经完全解析,需要手动过滤字段
|
||||||
|
if len(fields) > 0 {
|
||||||
|
filteredData := make(map[string]any)
|
||||||
|
for _, field := range fields {
|
||||||
|
if val, ok := row.Data[field]; ok {
|
||||||
|
filteredData[field] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.Data = filteredData
|
||||||
|
}
|
||||||
|
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询 SST 文件(按需解码)
|
||||||
|
return e.sstManager.GetPartial(seq, fields)
|
||||||
|
}
|
||||||
|
|
||||||
// switchMemTable 切换 MemTable
|
// switchMemTable 切换 MemTable
|
||||||
func (e *Engine) switchMemTable() error {
|
func (e *Engine) switchMemTable() error {
|
||||||
e.flushMu.Lock()
|
e.flushMu.Lock()
|
||||||
@@ -273,12 +306,12 @@ func (e *Engine) switchMemTable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// flushImmutable 将 Immutable MemTable 刷新到 SST
|
// flushImmutable 将 Immutable MemTable 刷新到 SST
|
||||||
func (e *Engine) flushImmutable(imm *memtable.ImmutableMemTable, walNumber int64) error {
|
func (e *Engine) flushImmutable(imm *ImmutableMemTable, walNumber int64) error {
|
||||||
// 1. 收集所有行
|
// 1. 收集所有行
|
||||||
var rows []*sst.Row
|
var rows []*SSTableRow
|
||||||
iter := imm.MemTable.NewIterator()
|
iter := imm.NewIterator()
|
||||||
for iter.Next() {
|
for iter.Next() {
|
||||||
var row sst.Row
|
var row SSTableRow
|
||||||
err := json.Unmarshal(iter.Value(), &row)
|
err := json.Unmarshal(iter.Value(), &row)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rows = append(rows, &row)
|
rows = append(rows, &row)
|
||||||
@@ -311,7 +344,7 @@ func (e *Engine) flushImmutable(imm *memtable.ImmutableMemTable, walNumber int64
|
|||||||
return fmt.Errorf("stat sst file: %w", err)
|
return fmt.Errorf("stat sst file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileMeta := &manifest.FileMetadata{
|
fileMeta := &FileMetadata{
|
||||||
FileNumber: fileNumber,
|
FileNumber: fileNumber,
|
||||||
Level: 0, // Flush 到 L0
|
Level: 0, // Flush 到 L0
|
||||||
FileSize: fileInfo.Size(),
|
FileSize: fileInfo.Size(),
|
||||||
@@ -321,11 +354,12 @@ func (e *Engine) flushImmutable(imm *memtable.ImmutableMemTable, walNumber int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 更新 MANIFEST
|
// 5. 更新 MANIFEST
|
||||||
edit := manifest.NewVersionEdit()
|
edit := NewVersionEdit()
|
||||||
edit.AddFile(fileMeta)
|
edit.AddFile(fileMeta)
|
||||||
|
|
||||||
// 持久化当前的文件编号计数器(关键修复:防止重启后文件编号重用)
|
// 持久化当前的文件编号计数器(关键修复:防止重启后文件编号重用)
|
||||||
edit.SetNextFileNumber(e.versionSet.GetNextFileNumber())
|
// 使用 fileNumber + 1 确保并发安全,避免竞态条件
|
||||||
|
edit.SetNextFileNumber(fileNumber + 1)
|
||||||
|
|
||||||
err = e.versionSet.LogAndApply(edit)
|
err = e.versionSet.LogAndApply(edit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,9 +372,9 @@ func (e *Engine) flushImmutable(imm *memtable.ImmutableMemTable, walNumber int64
|
|||||||
// 7. 从 Immutable 列表中移除
|
// 7. 从 Immutable 列表中移除
|
||||||
e.memtableManager.RemoveImmutable(imm)
|
e.memtableManager.RemoveImmutable(imm)
|
||||||
|
|
||||||
// 8. 触发 Compaction 检查(非阻塞)
|
// 8. Compaction 由后台线程负责,不在 flush 路径中触发
|
||||||
// Flush 后 L0 增加了新文件,可能需要立即触发 compaction
|
// 避免同步 compaction 导致刚创建的文件立即被删除
|
||||||
e.compactionManager.MaybeCompact()
|
// e.compactionManager.MaybeCompact()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -364,7 +398,7 @@ func (e *Engine) recover() error {
|
|||||||
|
|
||||||
// 依次读取每个 WAL
|
// 依次读取每个 WAL
|
||||||
for _, walPath := range walFiles {
|
for _, walPath := range walFiles {
|
||||||
reader, err := wal.NewReader(walPath)
|
reader, err := NewWALReader(walPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -380,7 +414,7 @@ func (e *Engine) recover() error {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
// 如果定义了 Schema,验证数据
|
// 如果定义了 Schema,验证数据
|
||||||
if e.schema != nil {
|
if e.schema != nil {
|
||||||
var row sst.Row
|
var row SSTableRow
|
||||||
if err := json.Unmarshal(entry.Data, &row); err != nil {
|
if err := json.Unmarshal(entry.Data, &row); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal row during recovery (seq=%d): %w", entry.Seq, err)
|
return fmt.Errorf("failed to unmarshal row during recovery (seq=%d): %w", entry.Seq, err)
|
||||||
}
|
}
|
||||||
@@ -445,8 +479,8 @@ func (e *Engine) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats 统计信息
|
// TableStats 统计信息
|
||||||
type Stats struct {
|
type TableStats struct {
|
||||||
MemTableSize int64
|
MemTableSize int64
|
||||||
MemTableCount int
|
MemTableCount int
|
||||||
SSTCount int
|
SSTCount int
|
||||||
@@ -454,22 +488,22 @@ type Stats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetVersionSet 获取 VersionSet(用于高级操作)
|
// GetVersionSet 获取 VersionSet(用于高级操作)
|
||||||
func (e *Engine) GetVersionSet() *manifest.VersionSet {
|
func (e *Engine) GetVersionSet() *VersionSet {
|
||||||
return e.versionSet
|
return e.versionSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCompactionManager 获取 Compaction Manager(用于高级操作)
|
// GetCompactionManager 获取 Compaction Manager(用于高级操作)
|
||||||
func (e *Engine) GetCompactionManager() *compaction.Manager {
|
func (e *Engine) GetCompactionManager() *CompactionManager {
|
||||||
return e.compactionManager
|
return e.compactionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMemtableManager 获取 Memtable Manager
|
// GetMemtableManager 获取 Memtable Manager
|
||||||
func (e *Engine) GetMemtableManager() *memtable.Manager {
|
func (e *Engine) GetMemtableManager() *MemTableManager {
|
||||||
return e.memtableManager
|
return e.memtableManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSSTManager 获取 SST Manager
|
// GetSSTManager 获取 SST Manager
|
||||||
func (e *Engine) GetSSTManager() *sst.Manager {
|
func (e *Engine) GetSSTManager() *SSTableManager {
|
||||||
return e.sstManager
|
return e.sstManager
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,11 +518,11 @@ func (e *Engine) GetSchema() *Schema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stats 获取统计信息
|
// Stats 获取统计信息
|
||||||
func (e *Engine) Stats() *Stats {
|
func (e *Engine) Stats() *TableStats {
|
||||||
memStats := e.memtableManager.GetStats()
|
memStats := e.memtableManager.GetStats()
|
||||||
sstStats := e.sstManager.GetStats()
|
sstStats := e.sstManager.GetStats()
|
||||||
|
|
||||||
stats := &Stats{
|
stats := &TableStats{
|
||||||
MemTableSize: memStats.TotalSize,
|
MemTableSize: memStats.TotalSize,
|
||||||
MemTableCount: memStats.TotalCount,
|
MemTableCount: memStats.TotalCount,
|
||||||
SSTCount: sstStats.FileCount,
|
SSTCount: sstStats.FileCount,
|
||||||
@@ -552,9 +586,9 @@ func (e *Engine) Query() *QueryBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scanAllWithBuilder 使用 QueryBuilder 全表扫描
|
// scanAllWithBuilder 使用 QueryBuilder 全表扫描
|
||||||
func (e *Engine) scanAllWithBuilder(qb *QueryBuilder) ([]*sst.Row, error) {
|
func (e *Engine) scanAllWithBuilder(qb *QueryBuilder) ([]*SSTableRow, error) {
|
||||||
// 使用 map 去重(同一个 seq 只保留一次)
|
// 使用 map 去重(同一个 seq 只保留一次)
|
||||||
rowMap := make(map[int64]*sst.Row)
|
rowMap := make(map[int64]*SSTableRow)
|
||||||
|
|
||||||
// 扫描 Active MemTable
|
// 扫描 Active MemTable
|
||||||
iter := e.memtableManager.NewIterator()
|
iter := e.memtableManager.NewIterator()
|
||||||
@@ -569,7 +603,7 @@ func (e *Engine) scanAllWithBuilder(qb *QueryBuilder) ([]*sst.Row, error) {
|
|||||||
// 扫描 Immutable MemTables
|
// 扫描 Immutable MemTables
|
||||||
immutables := e.memtableManager.GetImmutables()
|
immutables := e.memtableManager.GetImmutables()
|
||||||
for _, imm := range immutables {
|
for _, imm := range immutables {
|
||||||
iter := imm.MemTable.NewIterator()
|
iter := imm.NewIterator()
|
||||||
for iter.Next() {
|
for iter.Next() {
|
||||||
seq := iter.Key()
|
seq := iter.Key()
|
||||||
if _, exists := rowMap[seq]; !exists {
|
if _, exists := rowMap[seq]; !exists {
|
||||||
@@ -595,12 +629,17 @@ func (e *Engine) scanAllWithBuilder(qb *QueryBuilder) ([]*sst.Row, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为数组
|
// 转换为数组并按 Seq 排序
|
||||||
results := make([]*sst.Row, 0, len(rowMap))
|
results := make([]*SSTableRow, 0, len(rowMap))
|
||||||
for _, row := range rowMap {
|
for _, row := range rowMap {
|
||||||
results = append(results, row)
|
results = append(results, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按 Seq 排序(保证查询结果有序)
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Seq < results[j].Seq
|
||||||
|
})
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestEngine(t *testing.T) {
|
|||||||
|
|
||||||
// 2. 插入数据
|
// 2. 插入数据
|
||||||
for i := 1; i <= 100; i++ {
|
for i := 1; i <= 100; i++ {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"name": fmt.Sprintf("user_%d", i),
|
"name": fmt.Sprintf("user_%d", i),
|
||||||
"age": 20 + i%50,
|
"age": 20 + i%50,
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ func TestEngineFlush(t *testing.T) {
|
|||||||
|
|
||||||
// 插入足够多的数据触发 Flush
|
// 插入足够多的数据触发 Flush
|
||||||
for i := 1; i <= 200; i++ {
|
for i := 1; i <= 200; i++ {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"data": fmt.Sprintf("value_%d", i),
|
"data": fmt.Sprintf("value_%d", i),
|
||||||
}
|
}
|
||||||
engine.Insert(data)
|
engine.Insert(data)
|
||||||
@@ -177,12 +177,11 @@ func BenchmarkEngineInsert(b *testing.B) {
|
|||||||
})
|
})
|
||||||
defer engine.Close()
|
defer engine.Close()
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"value": 123,
|
"value": 123,
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ResetTimer()
|
for b.Loop() {
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
engine.Insert(data)
|
engine.Insert(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,14 +199,13 @@ func BenchmarkEngineGet(b *testing.B) {
|
|||||||
|
|
||||||
// 预先插入数据
|
// 预先插入数据
|
||||||
for i := 1; i <= 10000; i++ {
|
for i := 1; i <= 10000; i++ {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"value": i,
|
"value": i,
|
||||||
}
|
}
|
||||||
engine.Insert(data)
|
engine.Insert(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ResetTimer()
|
for i := 0; b.Loop(); i++ {
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
key := int64(i%10000 + 1)
|
key := int64(i%10000 + 1)
|
||||||
engine.Get(key)
|
engine.Get(key)
|
||||||
}
|
}
|
||||||
@@ -245,18 +243,18 @@ func TestHighConcurrencyWrite(t *testing.T) {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// 启动多个并发写入 goroutine
|
// 启动多个并发写入 goroutine
|
||||||
for i := 0; i < numGoroutines; i++ {
|
for i := range numGoroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(workerID int) {
|
go func(workerID int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
for j := 0; j < rowsPerWorker; j++ {
|
for j := range rowsPerWorker {
|
||||||
// 生成随机大小的数据 (2KB - 5MB)
|
// 生成随机大小的数据 (2KB - 5MB)
|
||||||
dataSize := minDataSize + (j % (maxDataSize - minDataSize))
|
dataSize := minDataSize + (j % (maxDataSize - minDataSize))
|
||||||
largeData := make([]byte, dataSize)
|
largeData := make([]byte, dataSize)
|
||||||
rand.Read(largeData)
|
rand.Read(largeData)
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"worker_id": workerID,
|
"worker_id": workerID,
|
||||||
"row_index": j,
|
"row_index": j,
|
||||||
"data_size": dataSize,
|
"data_size": dataSize,
|
||||||
@@ -352,7 +350,7 @@ func TestConcurrentReadWrite(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 启动写入 goroutines
|
// 启动写入 goroutines
|
||||||
for i := 0; i < numWriters; i++ {
|
for i := range numWriters {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(writerID int) {
|
go func(writerID int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -365,7 +363,7 @@ func TestConcurrentReadWrite(t *testing.T) {
|
|||||||
data := make([]byte, dataSize)
|
data := make([]byte, dataSize)
|
||||||
rand.Read(data)
|
rand.Read(data)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"writer_id": writerID,
|
"writer_id": writerID,
|
||||||
"data": data,
|
"data": data,
|
||||||
"timestamp": time.Now().UnixNano(),
|
"timestamp": time.Now().UnixNano(),
|
||||||
@@ -383,7 +381,7 @@ func TestConcurrentReadWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 启动读取 goroutines
|
// 启动读取 goroutines
|
||||||
for i := 0; i < numReaders; i++ {
|
for i := range numReaders {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(readerID int) {
|
go func(readerID int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -581,11 +579,11 @@ func TestCrashDuringCompaction(t *testing.T) {
|
|||||||
const numRows = 500
|
const numRows = 500
|
||||||
dataSize := 5 * 1024 // 5KB
|
dataSize := 5 * 1024 // 5KB
|
||||||
|
|
||||||
for i := 0; i < numRows; i++ {
|
for i := range numRows {
|
||||||
data := make([]byte, dataSize)
|
data := make([]byte, dataSize)
|
||||||
rand.Read(data)
|
rand.Read(data)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]any{
|
||||||
"index": i,
|
"index": i,
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
}
|
||||||
@@ -825,8 +823,8 @@ func TestEngineWithCompaction(t *testing.T) {
|
|||||||
const rowsPerBatch = 100
|
const rowsPerBatch = 100
|
||||||
|
|
||||||
for batch := range numBatches {
|
for batch := range numBatches {
|
||||||
for i := 0; i < rowsPerBatch; i++ {
|
for i := range rowsPerBatch {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"batch": batch,
|
"batch": batch,
|
||||||
"index": i,
|
"index": i,
|
||||||
"value": fmt.Sprintf("data-%d-%d", batch, i),
|
"value": fmt.Sprintf("data-%d-%d", batch, i),
|
||||||
@@ -906,8 +904,8 @@ func TestEngineWithCompaction(t *testing.T) {
|
|||||||
t.Logf("Engine stats: %d rows, %d SST files", stats.TotalRows, stats.SSTCount)
|
t.Logf("Engine stats: %d rows, %d SST files", stats.TotalRows, stats.SSTCount)
|
||||||
|
|
||||||
// 读取一些数据验证
|
// 读取一些数据验证
|
||||||
for batch := 0; batch < 3; batch++ {
|
for batch := range 3 {
|
||||||
for i := 0; i < 10; i++ {
|
for i := range 10 {
|
||||||
seq := int64(batch*rowsPerBatch + i + 1)
|
seq := int64(batch*rowsPerBatch + i + 1)
|
||||||
row, err := engine.Get(seq)
|
row, err := engine.Get(seq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -942,9 +940,9 @@ func TestEngineCompactionMerge(t *testing.T) {
|
|||||||
const rowsPerBatch = 50
|
const rowsPerBatch = 50
|
||||||
|
|
||||||
totalRows := 0
|
totalRows := 0
|
||||||
for batch := 0; batch < numBatches; batch++ {
|
for batch := range numBatches {
|
||||||
for i := 0; i < rowsPerBatch; i++ {
|
for i := range rowsPerBatch {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"batch": batch,
|
"batch": batch,
|
||||||
"index": i,
|
"index": i,
|
||||||
"value": fmt.Sprintf("v%d-%d", batch, i),
|
"value": fmt.Sprintf("v%d-%d", batch, i),
|
||||||
@@ -990,8 +988,8 @@ func TestEngineCompactionMerge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证数据完整性 - 检查前几条记录
|
// 验证数据完整性 - 检查前几条记录
|
||||||
for batch := 0; batch < 2; batch++ {
|
for batch := range 2 {
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
seq := int64(batch*rowsPerBatch + i + 1)
|
seq := int64(batch*rowsPerBatch + i + 1)
|
||||||
row, err := engine.Get(seq)
|
row, err := engine.Get(seq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1038,9 +1036,9 @@ func TestEngineBackgroundCompaction(t *testing.T) {
|
|||||||
const numBatches = 8
|
const numBatches = 8
|
||||||
const rowsPerBatch = 50
|
const rowsPerBatch = 50
|
||||||
|
|
||||||
for batch := 0; batch < numBatches; batch++ {
|
for batch := range numBatches {
|
||||||
for i := 0; i < rowsPerBatch; i++ {
|
for i := range rowsPerBatch {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"batch": batch,
|
"batch": batch,
|
||||||
"index": i,
|
"index": i,
|
||||||
}
|
}
|
||||||
@@ -1114,10 +1112,8 @@ func BenchmarkEngineWithCompaction(b *testing.B) {
|
|||||||
}
|
}
|
||||||
defer engine.Close()
|
defer engine.Close()
|
||||||
|
|
||||||
b.ResetTimer()
|
for i := 0; b.Loop(); i++ {
|
||||||
|
data := map[string]any{
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"index": i,
|
"index": i,
|
||||||
"value": fmt.Sprintf("benchmark-data-%d", i),
|
"value": fmt.Sprintf("benchmark-data-%d", i),
|
||||||
}
|
}
|
||||||
@@ -1168,7 +1164,7 @@ func TestEngineSchemaRecover(t *testing.T) {
|
|||||||
|
|
||||||
// 插入符合 Schema 的数据
|
// 插入符合 Schema 的数据
|
||||||
for i := 1; i <= 50; i++ {
|
for i := 1; i <= 50; i++ {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"name": fmt.Sprintf("user_%d", i),
|
"name": fmt.Sprintf("user_%d", i),
|
||||||
"age": 20 + i%50,
|
"age": 20 + i%50,
|
||||||
"email": fmt.Sprintf("user%d@example.com", i),
|
"email": fmt.Sprintf("user%d@example.com", i),
|
||||||
@@ -1233,7 +1229,7 @@ func TestEngineSchemaRecoverInvalid(t *testing.T) {
|
|||||||
|
|
||||||
// 插入一些不符合后续 Schema 的数据
|
// 插入一些不符合后续 Schema 的数据
|
||||||
for i := 1; i <= 10; i++ {
|
for i := 1; i <= 10; i++ {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"name": fmt.Sprintf("user_%d", i),
|
"name": fmt.Sprintf("user_%d", i),
|
||||||
"age": "invalid_age", // 这是字符串,但后续 Schema 要求 int64
|
"age": "invalid_age", // 这是字符串,但后续 Schema 要求 int64
|
||||||
}
|
}
|
||||||
@@ -1307,7 +1303,7 @@ func TestEngineAutoRecoverSchema(t *testing.T) {
|
|||||||
|
|
||||||
// 插入数据
|
// 插入数据
|
||||||
for i := 1; i <= 10; i++ {
|
for i := 1; i <= 10; i++ {
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"name": fmt.Sprintf("user_%d", i),
|
"name": fmt.Sprintf("user_%d", i),
|
||||||
"age": 20 + i,
|
"age": 20 + i,
|
||||||
}
|
}
|
||||||
@@ -1353,7 +1349,7 @@ func TestEngineAutoRecoverSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试插入新数据(应该符合恢复的 Schema)
|
// 尝试插入新数据(应该符合恢复的 Schema)
|
||||||
err = engine2.Insert(map[string]interface{}{
|
err = engine2.Insert(map[string]any{
|
||||||
"name": "new_user",
|
"name": "new_user",
|
||||||
"age": 30,
|
"age": 30,
|
||||||
})
|
})
|
||||||
@@ -1362,7 +1358,7 @@ func TestEngineAutoRecoverSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试插入不符合 Schema 的数据(应该失败)
|
// 尝试插入不符合 Schema 的数据(应该失败)
|
||||||
err = engine2.Insert(map[string]interface{}{
|
err = engine2.Insert(map[string]any{
|
||||||
"name": "bad_user",
|
"name": "bad_user",
|
||||||
"age": "invalid", // 类型错误
|
"age": "invalid", // 类型错误
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,254 +1,413 @@
|
|||||||
# SRDB Web UI Example
|
# SRDB WebUI - 数据库管理工具
|
||||||
|
|
||||||
这个示例展示了如何使用 SRDB 的内置 Web UI 来可视化查看数据库中的表和数据。
|
一个功能强大的 SRDB 数据库管理工具,集成了现代化的 Web 界面和实用的命令行工具。
|
||||||
|
|
||||||
## 功能特性
|
## 📋 目录
|
||||||
|
|
||||||
- 📊 **表列表展示** - 左侧显示所有表及其行数
|
- [功能特性](#功能特性)
|
||||||
- 🔍 **Schema 查看** - 点击箭头展开查看表的字段定义
|
- [快速开始](#快速开始)
|
||||||
- 📋 **数据分页浏览** - 右侧以表格形式展示数据,支持分页
|
- [Web UI 使用指南](#web-ui-使用指南)
|
||||||
- 🎨 **响应式设计** - 现代化的界面设计
|
- [命令行工具](#命令行工具)
|
||||||
- ⚡ **零构建** - 使用 HTMX 从 CDN 加载,无需构建步骤
|
- [技术架构](#技术架构)
|
||||||
- 💾 **大数据优化** - 自动截断显示,悬停查看,点击弹窗查看完整内容
|
- [开发说明](#开发说明)
|
||||||
- 📏 **数据大小显示** - 超过 1KB 的单元格自动显示大小标签
|
|
||||||
- 🔄 **后台数据插入** - 自动生成 2KB~512KB 的测试数据(每秒一条)
|
|
||||||
|
|
||||||
## 运行示例
|
---
|
||||||
|
|
||||||
|
## 🎯 功能特性
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
#### 📊 数据管理
|
||||||
|
- **表列表** - 查看所有表及其 Schema 信息
|
||||||
|
- **数据浏览** - 分页浏览表数据,支持自定义列选择
|
||||||
|
- **列持久化** - 自动保存列选择偏好到 localStorage
|
||||||
|
- **数据详情** - 点击查看完整的行数据(JSON 格式)
|
||||||
|
- **智能截断** - 长字符串自动截断,点击查看完整内容
|
||||||
|
- **时间格式化** - 自动格式化 `_time` 字段为可读时间
|
||||||
|
|
||||||
|
#### 🌳 LSM-Tree 管理
|
||||||
|
- **Manifest 视图** - 可视化 LSM-Tree 层级结构
|
||||||
|
- **文件详情** - 查看每层的 SST 文件信息
|
||||||
|
- **Compaction 监控** - 实时查看 Compaction Score 和统计
|
||||||
|
- **层级折叠** - 可展开/收起查看文件详情
|
||||||
|
|
||||||
|
#### 🎨 用户体验
|
||||||
|
- **响应式设计** - 完美适配桌面和移动设备
|
||||||
|
- **深色/浅色主题** - 支持主题切换
|
||||||
|
- **实时刷新** - 一键刷新当前视图数据
|
||||||
|
- **移动端优化** - 侧边栏抽屉式导航
|
||||||
|
|
||||||
|
### 命令行工具
|
||||||
|
|
||||||
|
提供多个实用的数据库诊断和管理工具:
|
||||||
|
|
||||||
|
| 命令 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `serve` | Web UI 服务器 | 启动 Web 管理界面 |
|
||||||
|
| `check-data` | 数据检查 | 检查表数据完整性 |
|
||||||
|
| `check-seq` | 序列号检查 | 验证特定序列号的数据 |
|
||||||
|
| `dump-manifest` | Manifest 导出 | 导出 LSM-Tree 结构信息 |
|
||||||
|
| `inspect-sst` | SST 文件检查 | 检查单个 SST 文件 |
|
||||||
|
| `inspect-all-sst` | 批量 SST 检查 | 检查所有 SST 文件 |
|
||||||
|
| `test-fix` | 修复测试 | 测试数据修复功能 |
|
||||||
|
| `test-keys` | 键测试 | 测试键的存在性 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 启动 Web UI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 进入示例目录
|
|
||||||
cd examples/webui
|
cd examples/webui
|
||||||
|
|
||||||
# 运行
|
# 使用默认配置(数据库:./data,端口:8080)
|
||||||
go run main.go
|
go run main.go serve
|
||||||
|
|
||||||
|
# 自定义配置
|
||||||
|
go run main.go serve --db /path/to/database --port 3000
|
||||||
|
|
||||||
|
# 启用自动数据插入(用于演示)
|
||||||
|
go run main.go serve --auto-insert
|
||||||
```
|
```
|
||||||
|
|
||||||
程序会:
|
### 2. 访问 Web UI
|
||||||
1. 创建/打开数据库目录 `./data`
|
|
||||||
2. 创建三个示例表:`users`、`products` 和 `logs`
|
|
||||||
3. 插入初始示例数据
|
|
||||||
4. **启动后台协程** - 每秒向 `logs` 表插入一条 2KB~512KB 的随机数据
|
|
||||||
5. 启动 Web 服务器在 `http://localhost:8080`
|
|
||||||
|
|
||||||
## 使用界面
|
打开浏览器访问:http://localhost:8080
|
||||||
|
|
||||||
打开浏览器访问 `http://localhost:8080`,你将看到:
|
### 3. 命令行工具示例
|
||||||
|
|
||||||
### 左侧边栏
|
|
||||||
- 显示所有表的列表
|
|
||||||
- 显示每个表的字段数量
|
|
||||||
- 点击 ▶ 图标展开查看字段信息
|
|
||||||
- 点击表名选择要查看的表(蓝色高亮显示当前选中)
|
|
||||||
|
|
||||||
### 右侧主区域
|
|
||||||
- **Schema 区域**:显示表结构和字段定义
|
|
||||||
- **Data 区域**:以表格形式显示数据
|
|
||||||
- 支持分页浏览(每页 20 条)
|
|
||||||
- 显示系统字段(_seq, _time)和用户字段
|
|
||||||
- **自动截断长数据**:超过 400px 的内容显示省略号
|
|
||||||
- **鼠标悬停**:悬停在单元格上查看完整内容
|
|
||||||
- **点击查看**:点击单元格在弹窗中查看完整内容
|
|
||||||
- **大小指示**:超过 1KB 的数据显示大小标签
|
|
||||||
|
|
||||||
### 大数据查看
|
|
||||||
1. **表格截断**:单元格最大宽度 400px,超长显示 `...`
|
|
||||||
2. **悬停展开**:鼠标悬停自动展开,黄色背景高亮
|
|
||||||
3. **模态框**:点击单元格弹出窗口
|
|
||||||
- 等宽字体显示(适合查看十六进制数据)
|
|
||||||
- 显示数据大小
|
|
||||||
- 支持滚动查看超长内容
|
|
||||||
|
|
||||||
## API 端点
|
|
||||||
|
|
||||||
Web UI 提供了以下 HTTP API:
|
|
||||||
|
|
||||||
### 获取所有表
|
|
||||||
```
|
|
||||||
GET /api/tables
|
|
||||||
```
|
|
||||||
|
|
||||||
返回示例:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "users",
|
|
||||||
"rowCount": 5,
|
|
||||||
"dir": "./data/users"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取表的 Schema
|
|
||||||
```
|
|
||||||
GET /api/tables/{name}/schema
|
|
||||||
```
|
|
||||||
|
|
||||||
返回示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fields": [
|
|
||||||
{"name": "name", "type": "string", "required": true},
|
|
||||||
{"name": "email", "type": "string", "required": true},
|
|
||||||
{"name": "age", "type": "int", "required": false}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取表数据(分页)
|
|
||||||
```
|
|
||||||
GET /api/tables/{name}/data?page=1&pageSize=20
|
|
||||||
```
|
|
||||||
|
|
||||||
参数:
|
|
||||||
- `page` - 页码,从 1 开始(默认:1)
|
|
||||||
- `pageSize` - 每页行数,最大 100(默认:20)
|
|
||||||
|
|
||||||
返回示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"page": 1,
|
|
||||||
"pageSize": 20,
|
|
||||||
"totalRows": 5,
|
|
||||||
"totalPages": 1,
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"_seq": 1,
|
|
||||||
"_time": 1234567890,
|
|
||||||
"name": "Alice",
|
|
||||||
"email": "alice@example.com",
|
|
||||||
"age": 30
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取表基本信息
|
|
||||||
```
|
|
||||||
GET /api/tables/{name}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 在你的应用中使用
|
|
||||||
|
|
||||||
你可以在自己的应用中轻松集成 Web UI:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"code.tczkiot.com/srdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// 打开数据库
|
|
||||||
db, _ := database.Open("./mydb")
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
// 获取 HTTP Handler
|
|
||||||
handler := db.WebUI()
|
|
||||||
|
|
||||||
// 启动服务器
|
|
||||||
http.ListenAndServe(":8080", handler)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
或者将其作为现有 Web 应用的一部分:
|
|
||||||
|
|
||||||
```go
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
// 你的其他路由
|
|
||||||
mux.HandleFunc("/api/myapp", myHandler)
|
|
||||||
|
|
||||||
// 挂载 SRDB Web UI 到 /admin/db 路径
|
|
||||||
mux.Handle("/admin/db/", http.StripPrefix("/admin/db", db.WebUI()))
|
|
||||||
|
|
||||||
http.ListenAndServe(":8080", mux)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **后端**: Go + 标准库 `net/http`
|
|
||||||
- **前端**: [HTMX](https://htmx.org/) + 原生 JavaScript + CSS
|
|
||||||
- **渲染**: 服务端 HTML 渲染(Go 模板生成)
|
|
||||||
- **字体**: Google Fonts (Inter)
|
|
||||||
- **无构建**: 直接从 CDN 加载 HTMX,无需 npm、webpack 等工具
|
|
||||||
- **部署**: 所有静态资源通过 `embed.FS` 嵌入到二进制文件中
|
|
||||||
|
|
||||||
## 测试大数据
|
|
||||||
|
|
||||||
### logs 表自动生成
|
|
||||||
|
|
||||||
程序会在后台持续向 `logs` 表插入大数据:
|
|
||||||
|
|
||||||
- **频率**:每秒一条
|
|
||||||
- **大小**:2KB ~ 512KB 随机
|
|
||||||
- **格式**:十六进制字符串
|
|
||||||
- **字段**:
|
|
||||||
- `timestamp` - 插入时间
|
|
||||||
- `data` - 随机数据(十六进制)
|
|
||||||
- `size_bytes` - 数据大小(字节)
|
|
||||||
|
|
||||||
你可以选择 `logs` 表来测试大数据的显示效果:
|
|
||||||
1. 单元格会显示数据大小标签(如 `245.12 KB`)
|
|
||||||
2. 内容被自动截断,显示省略号
|
|
||||||
3. 点击单元格在弹窗中查看完整数据
|
|
||||||
|
|
||||||
终端会实时输出插入日志:
|
|
||||||
```
|
|
||||||
Inserted record #1, size: 245.12 KB
|
|
||||||
Inserted record #2, size: 128.50 KB
|
|
||||||
Inserted record #3, size: 487.23 KB
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- Web UI 是只读的,不提供数据修改功能
|
|
||||||
- 适合用于开发、调试和数据查看
|
|
||||||
- 生产环境建议添加身份验证和访问控制
|
|
||||||
- 大数据量表的分页查询性能取决于数据分布
|
|
||||||
- `logs` 表会持续增长,可手动删除 `./data/logs` 目录重置
|
|
||||||
|
|
||||||
## Compaction 状态
|
|
||||||
|
|
||||||
由于后台持续插入大数据,会产生大量 SST 文件。SRDB 会自动运行 compaction 合并这些文件。
|
|
||||||
|
|
||||||
### 检查 Compaction 状态
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看 SST 文件分布
|
# 检查表数据
|
||||||
./check_sst.sh
|
go run main.go check-data --db ./data --table users
|
||||||
|
|
||||||
# 观察 webui 日志中的 [Compaction] 信息
|
# 检查特定序列号
|
||||||
|
go run main.go check-seq --db ./data --table users --seq 123
|
||||||
|
|
||||||
|
# 导出 Manifest
|
||||||
|
go run main.go dump-manifest --db ./data --table users
|
||||||
|
|
||||||
|
# 检查 SST 文件
|
||||||
|
go run main.go inspect-sst --db ./data --table users --file 000001.sst
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compaction 改进
|
---
|
||||||
|
|
||||||
- **触发阈值**: L0 文件数量 ≥2 就触发(之前是 4)
|
## 📖 Web UI 使用指南
|
||||||
- **运行频率**: 每 10 秒自动检查
|
|
||||||
- **日志增强**: 显示详细的 compaction 状态和统计
|
|
||||||
|
|
||||||
详细说明请查看 [COMPACTION.md](./COMPACTION.md)
|
### 界面布局
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### `invalid header` 错误
|
|
||||||
|
|
||||||
如果看到类似错误:
|
|
||||||
```
|
```
|
||||||
failed to open table logs: invalid header
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ SRDB Tables [🌙/☀️] │ ← 侧边栏
|
||||||
|
│ ├─ users │
|
||||||
|
│ ├─ orders │
|
||||||
|
│ └─ logs │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ [☰] users [🔄 Refresh] │ ← 页头
|
||||||
|
│ [Data] [Manifest / LSM-Tree] │ ← 视图切换
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Schema (点击字段卡片选择要显示的列) │ ← Schema 区域
|
||||||
|
│ ┌──────────┬──────────┬──────────┐ │
|
||||||
|
│ │⚡ id │● name │⚡ email │ │
|
||||||
|
│ │[int64] │[string] │[string] │ │
|
||||||
|
│ └──────────┴──────────┴──────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Data (1,234 rows) │ ← 数据表格
|
||||||
|
│ ┌─────┬──────┬───────────┬─────────┐ │
|
||||||
|
│ │ _seq│ name │ email │ Actions │ │
|
||||||
|
│ ├─────┼──────┼───────────┼─────────┤ │
|
||||||
|
│ │ 1 │ John │ john@... │ Detail │ │
|
||||||
|
│ │ 2 │ Jane │ jane@... │ Detail │ │
|
||||||
|
│ └─────┴──────┴───────────┴─────────┘ │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ [10/page] [Previous] Page 1 of 5 [Go] [Next] │ ← 分页控件
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**快速修复**:
|
### 功能说明
|
||||||
|
|
||||||
|
#### 1. 表列表(侧边栏)
|
||||||
|
- 显示所有表及其字段信息
|
||||||
|
- 点击表名切换到该表
|
||||||
|
- 展开/收起查看字段详情
|
||||||
|
- 字段图标:⚡ = 已索引,● = 未索引
|
||||||
|
|
||||||
|
#### 2. Data 视图
|
||||||
|
- **Schema 区域**:点击字段卡片选择要显示的列
|
||||||
|
- **数据表格**:显示选中的列数据
|
||||||
|
- **系统字段**:
|
||||||
|
- `_seq`:序列号(第一列)
|
||||||
|
- `_time`:时间戳(倒数第二列,自动格式化)
|
||||||
|
- **Detail 按钮**:查看完整的行数据(JSON 格式)
|
||||||
|
- **分页控件**:
|
||||||
|
- 每页大小:10/20/50/100
|
||||||
|
- 上一页/下一页
|
||||||
|
- 跳转到指定页
|
||||||
|
|
||||||
|
#### 3. Manifest 视图
|
||||||
|
- **统计卡片**:
|
||||||
|
- Active Levels:活跃层数
|
||||||
|
- Total Files:总文件数
|
||||||
|
- Total Size:总大小
|
||||||
|
- Compactions:Compaction 次数
|
||||||
|
- **层级卡片**:
|
||||||
|
- 点击展开/收起查看文件列表
|
||||||
|
- Score 指示器:
|
||||||
|
- 🟢 绿色:健康(< 50%)
|
||||||
|
- 🟡 黄色:警告(50-80%)
|
||||||
|
- 🔴 红色:需要 Compaction(≥ 80%)
|
||||||
|
- **文件详情**:
|
||||||
|
- 文件编号、大小、行数
|
||||||
|
- Seq 范围(min_key - max_key)
|
||||||
|
|
||||||
|
#### 4. 刷新按钮
|
||||||
|
- 点击刷新当前视图的数据
|
||||||
|
- Data 视图:重新加载表数据
|
||||||
|
- Manifest 视图:重新加载 LSM-Tree 结构
|
||||||
|
|
||||||
|
#### 5. 主题切换
|
||||||
|
- 点击右上角的 🌙/☀️ 图标
|
||||||
|
- 切换深色/浅色主题
|
||||||
|
- 自动保存到 localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 命令行工具
|
||||||
|
|
||||||
|
### serve - Web UI 服务器
|
||||||
|
|
||||||
|
启动 Web 管理界面。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./fix_corrupted_table.sh logs
|
go run main.go serve [flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
详见:[QUICK_FIX.md](./QUICK_FIX.md) 或 [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)
|
**参数**:
|
||||||
|
- `--db` - 数据库目录(默认:`./data`)
|
||||||
|
- `--port` - 服务端口(默认:`8080`)
|
||||||
|
- `--auto-insert` - 启用自动数据插入(用于演示)
|
||||||
|
|
||||||
## 更多信息
|
**示例**:
|
||||||
|
```bash
|
||||||
|
# 基本使用
|
||||||
|
go run main.go serve
|
||||||
|
|
||||||
- [FEATURES.md](./FEATURES.md) - 详细功能说明
|
# 自定义端口
|
||||||
- [COMPACTION.md](./COMPACTION.md) - Compaction 机制和诊断
|
go run main.go serve --port 3000
|
||||||
- [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) - 故障排除指南
|
|
||||||
- [QUICK_FIX.md](./QUICK_FIX.md) - 快速修复常见错误
|
# 启用自动插入(每秒插入一条随机数据到 logs 表)
|
||||||
|
go run main.go serve --auto-insert
|
||||||
|
```
|
||||||
|
|
||||||
|
### check-data - 数据检查
|
||||||
|
|
||||||
|
检查表数据的完整性。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go check-data --db ./data --table <table_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### check-seq - 序列号检查
|
||||||
|
|
||||||
|
验证特定序列号的数据。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go check-seq --db ./data --table <table_name> --seq <sequence_number>
|
||||||
|
```
|
||||||
|
|
||||||
|
### dump-manifest - Manifest 导出
|
||||||
|
|
||||||
|
导出 LSM-Tree 层级结构信息。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go dump-manifest --db ./data --table <table_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### inspect-sst - SST 文件检查
|
||||||
|
|
||||||
|
检查单个 SST 文件的内容和元数据。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go inspect-sst --db ./data --table <table_name> --file <file_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### inspect-all-sst - 批量 SST 检查
|
||||||
|
|
||||||
|
检查表的所有 SST 文件。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go inspect-all-sst --db ./data --table <table_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 技术架构
|
||||||
|
|
||||||
|
### 前端技术栈
|
||||||
|
|
||||||
|
- **Lit** - 轻量级 Web Components 框架
|
||||||
|
- **ES Modules** - 原生 JavaScript 模块
|
||||||
|
- **CSS Variables** - 主题系统
|
||||||
|
- **Shadow DOM** - 组件封装
|
||||||
|
|
||||||
|
### 组件架构
|
||||||
|
|
||||||
|
```
|
||||||
|
srdb-app (主应用)
|
||||||
|
├── srdb-theme-toggle (主题切换)
|
||||||
|
├── srdb-table-list (表列表)
|
||||||
|
├── srdb-page-header (页头)
|
||||||
|
│ └── [🔄 Refresh] (刷新按钮)
|
||||||
|
├── srdb-table-view (表视图容器)
|
||||||
|
│ ├── srdb-data-view (数据视图)
|
||||||
|
│ │ ├── Schema 区域
|
||||||
|
│ │ │ ├── srdb-field-icon (字段图标)
|
||||||
|
│ │ │ └── srdb-badge (类型标签)
|
||||||
|
│ │ └── 数据表格
|
||||||
|
│ └── srdb-manifest-view (Manifest 视图)
|
||||||
|
│ └── srdb-badge (Score 标签)
|
||||||
|
└── srdb-modal-dialog (模态对话框)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端架构
|
||||||
|
|
||||||
|
```
|
||||||
|
webui.go
|
||||||
|
├── API Endpoints
|
||||||
|
│ ├── GET /api/tables - 获取表列表
|
||||||
|
│ ├── GET /api/tables/{name}/schema - 获取表 Schema
|
||||||
|
│ ├── GET /api/tables/{name}/data - 获取表数据(分页)
|
||||||
|
│ ├── GET /api/tables/{name}/data/{seq} - 获取单条数据
|
||||||
|
│ └── GET /api/tables/{name}/manifest - 获取 Manifest 信息
|
||||||
|
├── Static Files
|
||||||
|
│ └── /static/* - 静态资源服务
|
||||||
|
└── Index
|
||||||
|
└── / - 首页
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
用户操作
|
||||||
|
↓
|
||||||
|
组件事件 (CustomEvent)
|
||||||
|
↓
|
||||||
|
app.js (事件总线)
|
||||||
|
↓
|
||||||
|
API 请求 (fetch)
|
||||||
|
↓
|
||||||
|
webui.go (HTTP Handler)
|
||||||
|
↓
|
||||||
|
SRDB Database
|
||||||
|
↓
|
||||||
|
JSON 响应
|
||||||
|
↓
|
||||||
|
组件更新 (Lit reactive)
|
||||||
|
↓
|
||||||
|
UI 渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 开发说明
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
webui/
|
||||||
|
├── commands/
|
||||||
|
│ └── webui.go # Web UI 服务器实现
|
||||||
|
├── static/
|
||||||
|
│ ├── index.html # 主页面
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── styles.css # 全局样式
|
||||||
|
│ └── js/
|
||||||
|
│ ├── app.js # 应用入口和事件总线
|
||||||
|
│ ├── components/ # Web Components
|
||||||
|
│ │ ├── app.js # 主应用容器
|
||||||
|
│ │ ├── badge.js # 标签组件
|
||||||
|
│ │ ├── data-view.js # 数据视图
|
||||||
|
│ │ ├── field-icon.js # 字段图标
|
||||||
|
│ │ ├── manifest-view.js # Manifest 视图
|
||||||
|
│ │ ├── modal-dialog.js # 模态对话框
|
||||||
|
│ │ ├── page-header.js # 页头
|
||||||
|
│ │ ├── table-list.js # 表列表
|
||||||
|
│ │ ├── table-view.js # 表视图容器
|
||||||
|
│ │ └── theme-toggle.js # 主题切换
|
||||||
|
│ └── styles/
|
||||||
|
│ └── shared-styles.js # 共享样式
|
||||||
|
└── webui.go # Web UI 后端实现
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加新组件
|
||||||
|
|
||||||
|
1. 在 `static/js/components/` 创建组件文件
|
||||||
|
2. 继承 `LitElement`
|
||||||
|
3. 定义 `static properties` 和 `static styles`
|
||||||
|
4. 实现 `render()` 方法
|
||||||
|
5. 使用 `customElements.define('srdb-xxx', Component)` 注册
|
||||||
|
6. 在 `app.js` 中导入
|
||||||
|
|
||||||
|
### 添加新 API
|
||||||
|
|
||||||
|
1. 在 `webui.go` 中添加 handler 方法
|
||||||
|
2. 在 `setupHandler()` 中注册路由
|
||||||
|
3. 返回 JSON 格式的响应
|
||||||
|
4. 在前端组件中调用 API
|
||||||
|
|
||||||
|
### 样式规范
|
||||||
|
|
||||||
|
- 使用 CSS Variables 定义颜色和尺寸
|
||||||
|
- 组件样式封装在 Shadow DOM 中
|
||||||
|
- 共享样式定义在 `shared-styles.js`
|
||||||
|
- 响应式断点:768px
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
|
||||||
|
- **组件名**:`srdb-xxx`(kebab-case)
|
||||||
|
- **类名**:`ComponentName`(PascalCase)
|
||||||
|
- **文件名**:`component-name.js`(kebab-case)
|
||||||
|
- **CSS 类**:`.class-name`(kebab-case)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 注意事项
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
|
||||||
|
1. **列选择**:只加载选中的列,减少数据传输
|
||||||
|
2. **字符串截断**:长字符串自动截断,按需加载完整内容
|
||||||
|
3. **分页加载**:大表数据分页加载,避免一次性加载全部
|
||||||
|
4. **Shadow DOM**:组件样式隔离,避免全局样式污染
|
||||||
|
|
||||||
|
### 浏览器兼容性
|
||||||
|
|
||||||
|
- Chrome 90+
|
||||||
|
- Firefox 88+
|
||||||
|
- Safari 14+
|
||||||
|
- Edge 90+
|
||||||
|
|
||||||
|
需要支持:
|
||||||
|
- ES Modules
|
||||||
|
- Web Components
|
||||||
|
- Shadow DOM
|
||||||
|
- CSS Variables
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
1. **大数据量**:单页最多显示 1000 条数据
|
||||||
|
2. **字符串长度**:超过 100 字符自动截断
|
||||||
|
3. **并发限制**:同时只能查看一个表的数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
"code.tczkiot.com/srdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InspectAllSST 检查所有 SST 文件
|
// InspectAllSST 检查所有 SST 文件
|
||||||
@@ -35,7 +35,7 @@ func InspectAllSST(sstDir string) {
|
|||||||
for _, filename := range sstFiles {
|
for _, filename := range sstFiles {
|
||||||
sstPath := filepath.Join(sstDir, filename)
|
sstPath := filepath.Join(sstDir, filename)
|
||||||
|
|
||||||
reader, err := sst.NewReader(sstPath)
|
reader, err := srdb.NewSSTableReader(sstPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("%s: ERROR - %v\n", filename, err)
|
fmt.Printf("%s: ERROR - %v\n", filename, err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
"code.tczkiot.com/srdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InspectSST 检查特定 SST 文件
|
// InspectSST 检查特定 SST 文件
|
||||||
@@ -19,7 +19,7 @@ func InspectSST(sstPath string) {
|
|||||||
fmt.Printf("Size: %d bytes\n", info.Size())
|
fmt.Printf("Size: %d bytes\n", info.Size())
|
||||||
|
|
||||||
// Open reader
|
// Open reader
|
||||||
reader, err := sst.NewReader(sstPath)
|
reader, err := srdb.NewSSTableReader(sstPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ func StartWebUI(dbPath string, addr string) {
|
|||||||
|
|
||||||
// 创建示例 Schema
|
// 创建示例 Schema
|
||||||
userSchema := srdb.NewSchema("users", []srdb.Field{
|
userSchema := srdb.NewSchema("users", []srdb.Field{
|
||||||
{Name: "name", Type: srdb.FieldTypeString, Indexed: false, Comment: "User name"},
|
{Name: "name", Type: srdb.FieldTypeString, Indexed: true, Comment: "User name"},
|
||||||
{Name: "email", Type: srdb.FieldTypeString, Indexed: false, Comment: "Email address"},
|
{Name: "email", Type: srdb.FieldTypeString, Indexed: false, Comment: "Email address"},
|
||||||
{Name: "age", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "Age"},
|
{Name: "age", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "Age"},
|
||||||
{Name: "city", Type: srdb.FieldTypeString, Indexed: false, Comment: "City"},
|
{Name: "city", Type: srdb.FieldTypeString, Indexed: false, Comment: "City"},
|
||||||
})
|
})
|
||||||
|
|
||||||
productSchema := srdb.NewSchema("products", []srdb.Field{
|
productSchema := srdb.NewSchema("products", []srdb.Field{
|
||||||
{Name: "product_name", Type: srdb.FieldTypeString, Indexed: false, Comment: "Product name"},
|
{Name: "product_name", Type: srdb.FieldTypeString, Indexed: true, Comment: "Product name"},
|
||||||
{Name: "price", Type: srdb.FieldTypeFloat, Indexed: false, Comment: "Price"},
|
{Name: "price", Type: srdb.FieldTypeFloat, Indexed: false, Comment: "Price"},
|
||||||
{Name: "quantity", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "Quantity"},
|
{Name: "quantity", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "Quantity"},
|
||||||
{Name: "category", Type: srdb.FieldTypeString, Indexed: false, Comment: "Category"},
|
{Name: "category", Type: srdb.FieldTypeString, Indexed: false, Comment: "Category"},
|
||||||
@@ -56,7 +56,7 @@ func StartWebUI(dbPath string, addr string) {
|
|||||||
log.Printf("Create users table failed: %v", err)
|
log.Printf("Create users table failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// 插入一些示例数据
|
// 插入一些示例数据
|
||||||
users := []map[string]interface{}{
|
users := []map[string]any{
|
||||||
{"name": "Alice", "email": "alice@example.com", "age": 30, "city": "Beijing"},
|
{"name": "Alice", "email": "alice@example.com", "age": 30, "city": "Beijing"},
|
||||||
{"name": "Bob", "email": "bob@example.com", "age": 25, "city": "Shanghai"},
|
{"name": "Bob", "email": "bob@example.com", "age": 25, "city": "Shanghai"},
|
||||||
{"name": "Charlie", "email": "charlie@example.com", "age": 35, "city": "Guangzhou"},
|
{"name": "Charlie", "email": "charlie@example.com", "age": 35, "city": "Guangzhou"},
|
||||||
@@ -76,7 +76,7 @@ func StartWebUI(dbPath string, addr string) {
|
|||||||
log.Printf("Create products table failed: %v", err)
|
log.Printf("Create products table failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// 插入一些示例数据
|
// 插入一些示例数据
|
||||||
products := []map[string]interface{}{
|
products := []map[string]any{
|
||||||
{"product_name": "Laptop", "price": 999.99, "quantity": 10, "category": "Electronics"},
|
{"product_name": "Laptop", "price": 999.99, "quantity": 10, "category": "Electronics"},
|
||||||
{"product_name": "Mouse", "price": 29.99, "quantity": 50, "category": "Electronics"},
|
{"product_name": "Mouse", "price": 29.99, "quantity": 50, "category": "Electronics"},
|
||||||
{"product_name": "Keyboard", "price": 79.99, "quantity": 30, "category": "Electronics"},
|
{"product_name": "Keyboard", "price": 79.99, "quantity": 30, "category": "Electronics"},
|
||||||
@@ -126,15 +126,15 @@ func autoInsertData(db *srdb.Database) {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
counter := 1
|
counter := 1
|
||||||
|
var logsTable *srdb.Table
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
tables := db.ListTables()
|
tables := db.ListTables()
|
||||||
var logsTable *srdb.Table
|
|
||||||
|
|
||||||
hasLogs := slices.Contains(tables, "logs")
|
hasLogs := slices.Contains(tables, "logs")
|
||||||
|
|
||||||
if !hasLogs {
|
if !hasLogs {
|
||||||
logsSchema := srdb.NewSchema("logs", []srdb.Field{
|
logsSchema := srdb.NewSchema("logs", []srdb.Field{
|
||||||
|
{Name: "group", Type: srdb.FieldTypeString, Indexed: true, Comment: "Log group (A-E)"},
|
||||||
{Name: "timestamp", Type: srdb.FieldTypeString, Indexed: false, Comment: "Timestamp"},
|
{Name: "timestamp", Type: srdb.FieldTypeString, Indexed: false, Comment: "Timestamp"},
|
||||||
{Name: "data", Type: srdb.FieldTypeString, Indexed: false, Comment: "Random data"},
|
{Name: "data", Type: srdb.FieldTypeString, Indexed: false, Comment: "Random data"},
|
||||||
{Name: "size_bytes", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "Data size in bytes"},
|
{Name: "size_bytes", Type: srdb.FieldTypeInt64, Indexed: false, Comment: "Data size in bytes"},
|
||||||
@@ -151,7 +151,6 @@ func autoInsertData(db *srdb.Database) {
|
|||||||
var err error
|
var err error
|
||||||
logsTable, err = db.GetTable("logs")
|
logsTable, err = db.GetTable("logs")
|
||||||
if err != nil || logsTable == nil {
|
if err != nil || logsTable == nil {
|
||||||
log.Printf("Failed to get logs table: %v", err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +158,12 @@ func autoInsertData(db *srdb.Database) {
|
|||||||
data := generateRandomData()
|
data := generateRandomData()
|
||||||
sizeBytes := len(data)
|
sizeBytes := len(data)
|
||||||
|
|
||||||
|
// 随机选择一个组 (A-E)
|
||||||
|
groups := []string{"A", "B", "C", "D", "E"}
|
||||||
|
group := groups[counter%len(groups)]
|
||||||
|
|
||||||
record := map[string]any{
|
record := map[string]any{
|
||||||
|
"group": group,
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
"data": data,
|
"data": data,
|
||||||
"size_bytes": int64(sizeBytes),
|
"size_bytes": int64(sizeBytes),
|
||||||
@@ -170,7 +174,7 @@ func autoInsertData(db *srdb.Database) {
|
|||||||
log.Printf("Failed to insert data: %v", err)
|
log.Printf("Failed to insert data: %v", err)
|
||||||
} else {
|
} else {
|
||||||
sizeStr := formatBytes(sizeBytes)
|
sizeStr := formatBytes(sizeBytes)
|
||||||
log.Printf("Inserted record #%d, size: %s", counter, sizeStr)
|
log.Printf("Inserted record #%d, group: %s, size: %s", counter, group, sizeStr)
|
||||||
counter++
|
counter++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
index.go
18
index.go
@@ -8,8 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/btree"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// IndexMetadata 索引元数据
|
// IndexMetadata 索引元数据
|
||||||
@@ -28,8 +26,8 @@ type SecondaryIndex struct {
|
|||||||
field string // 字段名
|
field string // 字段名
|
||||||
fieldType FieldType // 字段类型
|
fieldType FieldType // 字段类型
|
||||||
file *os.File // 索引文件
|
file *os.File // 索引文件
|
||||||
builder *btree.Builder // B+Tree 构建器
|
builder *BTreeBuilder // B+Tree 构建器
|
||||||
reader *btree.Reader // B+Tree 读取器
|
reader *BTreeReader // B+Tree 读取器
|
||||||
valueToSeq map[string][]int64 // 值 → seq 列表 (构建时使用)
|
valueToSeq map[string][]int64 // 值 → seq 列表 (构建时使用)
|
||||||
metadata IndexMetadata // 元数据
|
metadata IndexMetadata // 元数据
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -55,7 +53,7 @@ func NewSecondaryIndex(dir, field string, fieldType FieldType) (*SecondaryIndex,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add 添加索引条目
|
// Add 添加索引条目
|
||||||
func (idx *SecondaryIndex) Add(value interface{}, seq int64) error {
|
func (idx *SecondaryIndex) Add(value any, seq int64) error {
|
||||||
idx.mu.Lock()
|
idx.mu.Lock()
|
||||||
defer idx.mu.Unlock()
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
@@ -199,7 +197,7 @@ func (idx *SecondaryIndex) load() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get 查询索引
|
// Get 查询索引
|
||||||
func (idx *SecondaryIndex) Get(value interface{}) ([]int64, error) {
|
func (idx *SecondaryIndex) Get(value any) ([]int64, error) {
|
||||||
idx.mu.RLock()
|
idx.mu.RLock()
|
||||||
defer idx.mu.RUnlock()
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
@@ -238,7 +236,7 @@ func (idx *SecondaryIndex) NeedsUpdate(currentMaxSeq int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IncrementalUpdate 增量更新索引
|
// IncrementalUpdate 增量更新索引
|
||||||
func (idx *SecondaryIndex) IncrementalUpdate(getData func(int64) (map[string]interface{}, error), fromSeq, toSeq int64) error {
|
func (idx *SecondaryIndex) IncrementalUpdate(getData func(int64) (map[string]any, error), fromSeq, toSeq int64) error {
|
||||||
idx.mu.Lock()
|
idx.mu.Lock()
|
||||||
defer idx.mu.Unlock()
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
@@ -286,7 +284,7 @@ func encodeSeqList(seqs []int64) []byte {
|
|||||||
func decodeSeqList(data []byte) []int64 {
|
func decodeSeqList(data []byte) []int64 {
|
||||||
count := len(data) / 8
|
count := len(data) / 8
|
||||||
seqs := make([]int64, count)
|
seqs := make([]int64, count)
|
||||||
for i := 0; i < count; i++ {
|
for i := range count {
|
||||||
seqs[i] = int64(binary.LittleEndian.Uint64(data[i*8:]))
|
seqs[i] = int64(binary.LittleEndian.Uint64(data[i*8:]))
|
||||||
}
|
}
|
||||||
return seqs
|
return seqs
|
||||||
@@ -433,7 +431,7 @@ func (m *IndexManager) GetIndex(field string) (*SecondaryIndex, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddToIndexes 添加到所有索引
|
// AddToIndexes 添加到所有索引
|
||||||
func (m *IndexManager) AddToIndexes(data map[string]interface{}, seq int64) error {
|
func (m *IndexManager) AddToIndexes(data map[string]any, seq int64) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -477,7 +475,7 @@ func (m *IndexManager) ListIndexes() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VerifyAndRepair 验证并修复所有索引
|
// VerifyAndRepair 验证并修复所有索引
|
||||||
func (m *IndexManager) VerifyAndRepair(currentMaxSeq int64, getData func(int64) (map[string]interface{}, error)) error {
|
func (m *IndexManager) VerifyAndRepair(currentMaxSeq int64, getData func(int64) (map[string]any, error)) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
indexes := make(map[string]*SecondaryIndex)
|
indexes := make(map[string]*SecondaryIndex)
|
||||||
for k, v := range m.indexes {
|
for k, v := range m.indexes {
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ func TestIncrementalUpdate(t *testing.T) {
|
|||||||
t.Logf("Initial: MaxSeq=%d, RowCount=%d", initialMetadata.MaxSeq, initialMetadata.RowCount)
|
t.Logf("Initial: MaxSeq=%d, RowCount=%d", initialMetadata.MaxSeq, initialMetadata.RowCount)
|
||||||
|
|
||||||
// 2. 模拟新数据
|
// 2. 模拟新数据
|
||||||
mockData := map[int64]map[string]interface{}{
|
mockData := map[int64]map[string]any{
|
||||||
3: {"name": "Charlie"},
|
3: {"name": "Charlie"},
|
||||||
4: {"name": "David"},
|
4: {"name": "David"},
|
||||||
5: {"name": "Alice"},
|
5: {"name": "Alice"},
|
||||||
}
|
}
|
||||||
|
|
||||||
getData := func(seq int64) (map[string]interface{}, error) {
|
getData := func(seq int64) (map[string]any, error) {
|
||||||
if data, exists := mockData[seq]; exists {
|
if data, exists := mockData[seq]; exists {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reader MANIFEST 读取器
|
|
||||||
type Reader struct {
|
|
||||||
file io.Reader
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReader 创建 MANIFEST 读取器
|
|
||||||
func NewReader(file io.Reader) *Reader {
|
|
||||||
return &Reader{
|
|
||||||
file: file,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadEdit 读取版本变更
|
|
||||||
func (r *Reader) ReadEdit() (*VersionEdit, error) {
|
|
||||||
// 读取 CRC32 和 Length
|
|
||||||
header := make([]byte, 8)
|
|
||||||
_, err := io.ReadFull(r.file, header)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取长度
|
|
||||||
length := binary.LittleEndian.Uint32(header[4:8])
|
|
||||||
|
|
||||||
// 读取数据
|
|
||||||
data := make([]byte, 8+length)
|
|
||||||
copy(data[0:8], header)
|
|
||||||
_, err = io.ReadFull(r.file, data[8:])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解码
|
|
||||||
edit := NewVersionEdit()
|
|
||||||
err = edit.Decode(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return edit, nil
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Writer MANIFEST 写入器
|
|
||||||
type Writer struct {
|
|
||||||
file io.Writer
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWriter 创建 MANIFEST 写入器
|
|
||||||
func NewWriter(file io.Writer) *Writer {
|
|
||||||
return &Writer{
|
|
||||||
file: file,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteEdit 写入版本变更
|
|
||||||
func (w *Writer) WriteEdit(edit *VersionEdit) error {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
// 编码
|
|
||||||
data, err := edit.Encode()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入
|
|
||||||
_, err = w.file.Write(data)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileMetadata SST 文件元数据
|
|
||||||
type FileMetadata struct {
|
|
||||||
FileNumber int64 // 文件编号
|
|
||||||
Level int // 所在层级 (0-6)
|
|
||||||
FileSize int64 // 文件大小
|
|
||||||
MinKey int64 // 最小 key
|
|
||||||
MaxKey int64 // 最大 key
|
|
||||||
RowCount int64 // 行数
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
NumLevels = 7 // L0-L6
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version 数据库的一个版本快照
|
|
||||||
type Version struct {
|
|
||||||
// 分层存储 SST 文件 (L0-L6)
|
|
||||||
Levels [NumLevels][]*FileMetadata
|
|
||||||
|
|
||||||
// 下一个文件编号
|
|
||||||
NextFileNumber int64
|
|
||||||
|
|
||||||
// 最后序列号
|
|
||||||
LastSequence int64
|
|
||||||
|
|
||||||
// 版本号
|
|
||||||
VersionNumber int64
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVersion 创建新版本
|
|
||||||
func NewVersion() *Version {
|
|
||||||
v := &Version{
|
|
||||||
NextFileNumber: 1,
|
|
||||||
LastSequence: 0,
|
|
||||||
VersionNumber: 0,
|
|
||||||
}
|
|
||||||
// 初始化每一层
|
|
||||||
for i := 0; i < NumLevels; i++ {
|
|
||||||
v.Levels[i] = make([]*FileMetadata, 0)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone 克隆版本
|
|
||||||
func (v *Version) Clone() *Version {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
|
|
||||||
newVersion := &Version{
|
|
||||||
NextFileNumber: v.NextFileNumber,
|
|
||||||
LastSequence: v.LastSequence,
|
|
||||||
VersionNumber: v.VersionNumber + 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 克隆每一层
|
|
||||||
for level := 0; level < NumLevels; level++ {
|
|
||||||
newVersion.Levels[level] = make([]*FileMetadata, len(v.Levels[level]))
|
|
||||||
copy(newVersion.Levels[level], v.Levels[level])
|
|
||||||
}
|
|
||||||
|
|
||||||
return newVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply 应用版本变更
|
|
||||||
func (v *Version) Apply(edit *VersionEdit) {
|
|
||||||
v.mu.Lock()
|
|
||||||
defer v.mu.Unlock()
|
|
||||||
|
|
||||||
// 删除文件(按层级删除)
|
|
||||||
if len(edit.DeletedFiles) > 0 {
|
|
||||||
deleteSet := make(map[int64]bool)
|
|
||||||
for _, fileNum := range edit.DeletedFiles {
|
|
||||||
deleteSet[fileNum] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历每一层,删除文件
|
|
||||||
for level := 0; level < NumLevels; level++ {
|
|
||||||
newFiles := make([]*FileMetadata, 0)
|
|
||||||
deletedCount := 0
|
|
||||||
for _, file := range v.Levels[level] {
|
|
||||||
if !deleteSet[file.FileNumber] {
|
|
||||||
newFiles = append(newFiles, file)
|
|
||||||
} else {
|
|
||||||
deletedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if deletedCount > 0 {
|
|
||||||
fmt.Printf("[Version.Apply] L%d: deleted %d files\n", level, deletedCount)
|
|
||||||
}
|
|
||||||
v.Levels[level] = newFiles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加文件(按层级添加)
|
|
||||||
if len(edit.AddedFiles) > 0 {
|
|
||||||
for _, file := range edit.AddedFiles {
|
|
||||||
if file.Level >= 0 && file.Level < NumLevels {
|
|
||||||
fmt.Printf("[Version.Apply] Adding file #%d to L%d (keys %d-%d)\n",
|
|
||||||
file.FileNumber, file.Level, file.MinKey, file.MaxKey)
|
|
||||||
v.Levels[file.Level] = append(v.Levels[file.Level], file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新下一个文件编号
|
|
||||||
if edit.NextFileNumber != nil {
|
|
||||||
v.NextFileNumber = *edit.NextFileNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新最后序列号
|
|
||||||
if edit.LastSequence != nil {
|
|
||||||
v.LastSequence = *edit.LastSequence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLevel 获取指定层级的文件
|
|
||||||
func (v *Version) GetLevel(level int) []*FileMetadata {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
|
|
||||||
if level < 0 || level >= NumLevels {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
files := make([]*FileMetadata, len(v.Levels[level]))
|
|
||||||
copy(files, v.Levels[level])
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSSTFiles 获取所有 SST 文件(副本,兼容旧接口)
|
|
||||||
func (v *Version) GetSSTFiles() []*FileMetadata {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
|
|
||||||
// 收集所有层级的文件
|
|
||||||
allFiles := make([]*FileMetadata, 0)
|
|
||||||
for level := 0; level < NumLevels; level++ {
|
|
||||||
allFiles = append(allFiles, v.Levels[level]...)
|
|
||||||
}
|
|
||||||
return allFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNextFileNumber 获取下一个文件编号
|
|
||||||
func (v *Version) GetNextFileNumber() int64 {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
return v.NextFileNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLastSequence 获取最后序列号
|
|
||||||
func (v *Version) GetLastSequence() int64 {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
return v.LastSequence
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFileCount 获取文件数量
|
|
||||||
func (v *Version) GetFileCount() int {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
|
|
||||||
total := 0
|
|
||||||
for level := 0; level < NumLevels; level++ {
|
|
||||||
total += len(v.Levels[level])
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLevelFileCount 获取指定层级的文件数量
|
|
||||||
func (v *Version) GetLevelFileCount(level int) int {
|
|
||||||
v.mu.RLock()
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
|
|
||||||
if level < 0 || level >= NumLevels {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return len(v.Levels[level])
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"hash/crc32"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EditType 变更类型
|
|
||||||
type EditType byte
|
|
||||||
|
|
||||||
const (
|
|
||||||
EditTypeAddFile EditType = 1 // 添加文件
|
|
||||||
EditTypeDeleteFile EditType = 2 // 删除文件
|
|
||||||
EditTypeSetNextFile EditType = 3 // 设置下一个文件编号
|
|
||||||
EditTypeSetLastSeq EditType = 4 // 设置最后序列号
|
|
||||||
)
|
|
||||||
|
|
||||||
// VersionEdit 版本变更记录
|
|
||||||
type VersionEdit struct {
|
|
||||||
// 添加的文件
|
|
||||||
AddedFiles []*FileMetadata
|
|
||||||
|
|
||||||
// 删除的文件(文件编号列表)
|
|
||||||
DeletedFiles []int64
|
|
||||||
|
|
||||||
// 下一个文件编号
|
|
||||||
NextFileNumber *int64
|
|
||||||
|
|
||||||
// 最后序列号
|
|
||||||
LastSequence *int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVersionEdit 创建版本变更
|
|
||||||
func NewVersionEdit() *VersionEdit {
|
|
||||||
return &VersionEdit{
|
|
||||||
AddedFiles: make([]*FileMetadata, 0),
|
|
||||||
DeletedFiles: make([]int64, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFile 添加文件
|
|
||||||
func (e *VersionEdit) AddFile(file *FileMetadata) {
|
|
||||||
e.AddedFiles = append(e.AddedFiles, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteFile 删除文件
|
|
||||||
func (e *VersionEdit) DeleteFile(fileNumber int64) {
|
|
||||||
e.DeletedFiles = append(e.DeletedFiles, fileNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetNextFileNumber 设置下一个文件编号
|
|
||||||
func (e *VersionEdit) SetNextFileNumber(num int64) {
|
|
||||||
e.NextFileNumber = &num
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLastSequence 设置最后序列号
|
|
||||||
func (e *VersionEdit) SetLastSequence(seq int64) {
|
|
||||||
e.LastSequence = &seq
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode 编码为字节
|
|
||||||
func (e *VersionEdit) Encode() ([]byte, error) {
|
|
||||||
// 使用 JSON 编码(简单实现)
|
|
||||||
data, err := json.Marshal(e)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式: CRC32(4) + Length(4) + Data
|
|
||||||
totalLen := 8 + len(data)
|
|
||||||
buf := make([]byte, totalLen)
|
|
||||||
|
|
||||||
// 计算 CRC32
|
|
||||||
crc := crc32.ChecksumIEEE(data)
|
|
||||||
binary.LittleEndian.PutUint32(buf[0:4], crc)
|
|
||||||
|
|
||||||
// 写入长度
|
|
||||||
binary.LittleEndian.PutUint32(buf[4:8], uint32(len(data)))
|
|
||||||
|
|
||||||
// 写入数据
|
|
||||||
copy(buf[8:], data)
|
|
||||||
|
|
||||||
return buf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode 从字节解码
|
|
||||||
func (e *VersionEdit) Decode(data []byte) error {
|
|
||||||
if len(data) < 8 {
|
|
||||||
return io.ErrUnexpectedEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 CRC32
|
|
||||||
crc := binary.LittleEndian.Uint32(data[0:4])
|
|
||||||
|
|
||||||
// 读取长度
|
|
||||||
length := binary.LittleEndian.Uint32(data[4:8])
|
|
||||||
|
|
||||||
if len(data) < int(8+length) {
|
|
||||||
return io.ErrUnexpectedEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取数据
|
|
||||||
editData := data[8 : 8+length]
|
|
||||||
|
|
||||||
// 验证 CRC32
|
|
||||||
if crc32.ChecksumIEEE(editData) != crc {
|
|
||||||
return io.ErrUnexpectedEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON 解码
|
|
||||||
return json.Unmarshal(editData, e)
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VersionSet 版本集合管理器
|
|
||||||
type VersionSet struct {
|
|
||||||
// 当前版本
|
|
||||||
current *Version
|
|
||||||
|
|
||||||
// MANIFEST 文件
|
|
||||||
manifestFile *os.File
|
|
||||||
manifestWriter *Writer
|
|
||||||
manifestNumber int64
|
|
||||||
|
|
||||||
// 下一个文件编号
|
|
||||||
nextFileNumber atomic.Int64
|
|
||||||
|
|
||||||
// 最后序列号
|
|
||||||
lastSequence atomic.Int64
|
|
||||||
|
|
||||||
// 目录
|
|
||||||
dir string
|
|
||||||
|
|
||||||
// 锁
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewVersionSet 创建版本集合
|
|
||||||
func NewVersionSet(dir string) (*VersionSet, error) {
|
|
||||||
vs := &VersionSet{
|
|
||||||
dir: dir,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保目录存在
|
|
||||||
err := os.MkdirAll(dir, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 CURRENT 文件
|
|
||||||
currentFile := filepath.Join(dir, "CURRENT")
|
|
||||||
data, err := os.ReadFile(currentFile)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// CURRENT 不存在,创建新的 MANIFEST
|
|
||||||
return vs, vs.createNewManifest()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 MANIFEST 文件
|
|
||||||
manifestName := strings.TrimSpace(string(data))
|
|
||||||
manifestPath := filepath.Join(dir, manifestName)
|
|
||||||
|
|
||||||
// 恢复版本信息
|
|
||||||
version, err := vs.recoverFromManifest(manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
vs.current = version
|
|
||||||
vs.nextFileNumber.Store(version.NextFileNumber)
|
|
||||||
vs.lastSequence.Store(version.LastSequence)
|
|
||||||
|
|
||||||
// 解析 MANIFEST 编号
|
|
||||||
fmt.Sscanf(manifestName, "MANIFEST-%d", &vs.manifestNumber)
|
|
||||||
|
|
||||||
// 打开 MANIFEST 用于追加
|
|
||||||
file, err := os.OpenFile(manifestPath, os.O_APPEND|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
vs.manifestFile = file
|
|
||||||
vs.manifestWriter = NewWriter(file)
|
|
||||||
|
|
||||||
return vs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createNewManifest 创建新的 MANIFEST
|
|
||||||
func (vs *VersionSet) createNewManifest() error {
|
|
||||||
// 生成新的 MANIFEST 文件名
|
|
||||||
vs.manifestNumber = vs.nextFileNumber.Add(1)
|
|
||||||
manifestName := fmt.Sprintf("MANIFEST-%06d", vs.manifestNumber)
|
|
||||||
manifestPath := filepath.Join(vs.dir, manifestName)
|
|
||||||
|
|
||||||
// 创建 MANIFEST 文件
|
|
||||||
file, err := os.Create(manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
vs.manifestFile = file
|
|
||||||
vs.manifestWriter = NewWriter(file)
|
|
||||||
|
|
||||||
// 创建初始版本
|
|
||||||
vs.current = NewVersion()
|
|
||||||
|
|
||||||
// 写入初始版本
|
|
||||||
edit := NewVersionEdit()
|
|
||||||
nextFile := vs.manifestNumber
|
|
||||||
edit.SetNextFileNumber(nextFile)
|
|
||||||
lastSeq := int64(0)
|
|
||||||
edit.SetLastSequence(lastSeq)
|
|
||||||
|
|
||||||
err = vs.manifestWriter.WriteEdit(edit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步到磁盘
|
|
||||||
err = vs.manifestFile.Sync()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 CURRENT 文件
|
|
||||||
return vs.updateCurrent(manifestName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// recoverFromManifest 从 MANIFEST 恢复版本
|
|
||||||
func (vs *VersionSet) recoverFromManifest(manifestPath string) (*Version, error) {
|
|
||||||
// 打开 MANIFEST 文件
|
|
||||||
file, err := os.Open(manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
reader := NewReader(file)
|
|
||||||
|
|
||||||
// 创建初始版本
|
|
||||||
version := NewVersion()
|
|
||||||
|
|
||||||
// 读取所有 VersionEdit
|
|
||||||
for {
|
|
||||||
edit, err := reader.ReadEdit()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用变更
|
|
||||||
version.Apply(edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateCurrent 更新 CURRENT 文件
|
|
||||||
func (vs *VersionSet) updateCurrent(manifestName string) error {
|
|
||||||
currentPath := filepath.Join(vs.dir, "CURRENT")
|
|
||||||
tmpPath := currentPath + ".tmp"
|
|
||||||
|
|
||||||
// 1. 写入临时文件
|
|
||||||
err := os.WriteFile(tmpPath, []byte(manifestName+"\n"), 0644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 原子性重命名
|
|
||||||
err = os.Rename(tmpPath, currentPath)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogAndApply 记录并应用版本变更
|
|
||||||
func (vs *VersionSet) LogAndApply(edit *VersionEdit) error {
|
|
||||||
vs.mu.Lock()
|
|
||||||
defer vs.mu.Unlock()
|
|
||||||
|
|
||||||
// 1. 创建新版本
|
|
||||||
newVersion := vs.current.Clone()
|
|
||||||
|
|
||||||
// 2. 应用变更
|
|
||||||
newVersion.Apply(edit)
|
|
||||||
|
|
||||||
// 3. 写入 MANIFEST
|
|
||||||
err := vs.manifestWriter.WriteEdit(edit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 同步到磁盘
|
|
||||||
err = vs.manifestFile.Sync()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 更新当前版本
|
|
||||||
vs.current = newVersion
|
|
||||||
|
|
||||||
// 6. 更新原子变量
|
|
||||||
if edit.NextFileNumber != nil {
|
|
||||||
vs.nextFileNumber.Store(*edit.NextFileNumber)
|
|
||||||
}
|
|
||||||
if edit.LastSequence != nil {
|
|
||||||
vs.lastSequence.Store(*edit.LastSequence)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrent 获取当前版本
|
|
||||||
func (vs *VersionSet) GetCurrent() *Version {
|
|
||||||
vs.mu.RLock()
|
|
||||||
defer vs.mu.RUnlock()
|
|
||||||
return vs.current
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNextFileNumber 获取下一个文件编号
|
|
||||||
func (vs *VersionSet) GetNextFileNumber() int64 {
|
|
||||||
return vs.nextFileNumber.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllocateFileNumber 分配文件编号
|
|
||||||
func (vs *VersionSet) AllocateFileNumber() int64 {
|
|
||||||
return vs.nextFileNumber.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLastSequence 获取最后序列号
|
|
||||||
func (vs *VersionSet) GetLastSequence() int64 {
|
|
||||||
return vs.lastSequence.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLastSequence 设置最后序列号
|
|
||||||
func (vs *VersionSet) SetLastSequence(seq int64) {
|
|
||||||
vs.lastSequence.Store(seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭 VersionSet
|
|
||||||
func (vs *VersionSet) Close() error {
|
|
||||||
vs.mu.Lock()
|
|
||||||
defer vs.mu.Unlock()
|
|
||||||
|
|
||||||
if vs.manifestFile != nil {
|
|
||||||
return vs.manifestFile.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,151 @@
|
|||||||
package memtable
|
package srdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MemTable 内存表
|
||||||
|
type MemTable struct {
|
||||||
|
data map[int64][]byte // key -> value
|
||||||
|
keys []int64 // 排序的 keys
|
||||||
|
size int64 // 数据大小
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemTable 创建 MemTable
|
||||||
|
func NewMemTable() *MemTable {
|
||||||
|
return &MemTable{
|
||||||
|
data: make(map[int64][]byte),
|
||||||
|
keys: make([]int64, 0),
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put 插入数据
|
||||||
|
func (m *MemTable) Put(key int64, value []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
if _, exists := m.data[key]; !exists {
|
||||||
|
m.keys = append(m.keys, key)
|
||||||
|
// 保持 keys 有序
|
||||||
|
slices.Sort(m.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.data[key] = value
|
||||||
|
m.size += int64(len(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 查询数据
|
||||||
|
func (m *MemTable) Get(key int64) ([]byte, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
value, exists := m.data[key]
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size 获取大小
|
||||||
|
func (m *MemTable) Size() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
return m.size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 获取条目数量
|
||||||
|
func (m *MemTable) Count() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
return len(m.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys 获取所有 keys 的副本(已排序)
|
||||||
|
func (m *MemTable) Keys() []int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
// 返回副本以避免并发问题
|
||||||
|
keysCopy := make([]int64, len(m.keys))
|
||||||
|
copy(keysCopy, m.keys)
|
||||||
|
return keysCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemTableIterator 迭代器
|
||||||
|
type MemTableIterator struct {
|
||||||
|
mt *MemTable
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIterator 创建迭代器
|
||||||
|
func (m *MemTable) NewIterator() *MemTableIterator {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
return &MemTableIterator{
|
||||||
|
mt: m,
|
||||||
|
index: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next 移动到下一个
|
||||||
|
func (it *MemTableIterator) Next() bool {
|
||||||
|
it.mt.mu.RLock()
|
||||||
|
defer it.mt.mu.RUnlock()
|
||||||
|
|
||||||
|
it.index++
|
||||||
|
return it.index < len(it.mt.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key 当前 key
|
||||||
|
func (it *MemTableIterator) Key() int64 {
|
||||||
|
it.mt.mu.RLock()
|
||||||
|
defer it.mt.mu.RUnlock()
|
||||||
|
|
||||||
|
if it.index < 0 || it.index >= len(it.mt.keys) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return it.mt.keys[it.index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value 当前 value
|
||||||
|
func (it *MemTableIterator) Value() []byte {
|
||||||
|
it.mt.mu.RLock()
|
||||||
|
defer it.mt.mu.RUnlock()
|
||||||
|
|
||||||
|
if it.index < 0 || it.index >= len(it.mt.keys) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := it.mt.keys[it.index]
|
||||||
|
return it.mt.data[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset 重置迭代器
|
||||||
|
func (it *MemTableIterator) Reset() {
|
||||||
|
it.index = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear 清空 MemTable
|
||||||
|
func (m *MemTable) Clear() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.data = make(map[int64][]byte)
|
||||||
|
m.keys = make([]int64, 0)
|
||||||
|
m.size = 0
|
||||||
|
}
|
||||||
|
|
||||||
// ImmutableMemTable 不可变的 MemTable
|
// ImmutableMemTable 不可变的 MemTable
|
||||||
type ImmutableMemTable struct {
|
type ImmutableMemTable struct {
|
||||||
MemTable *MemTable
|
*MemTable
|
||||||
WALNumber int64 // 对应的 WAL 编号
|
WALNumber int64 // 对应的 WAL 编号
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager MemTable 管理器
|
// MemTableManager MemTable 管理器
|
||||||
type Manager struct {
|
type MemTableManager struct {
|
||||||
active *MemTable // Active MemTable (可写)
|
active *MemTable // Active MemTable (可写)
|
||||||
immutables []*ImmutableMemTable // Immutable MemTables (只读)
|
immutables []*ImmutableMemTable // Immutable MemTables (只读)
|
||||||
activeWAL int64 // Active MemTable 对应的 WAL 编号
|
activeWAL int64 // Active MemTable 对应的 WAL 编号
|
||||||
@@ -19,31 +153,31 @@ type Manager struct {
|
|||||||
mu sync.RWMutex // 读写锁
|
mu sync.RWMutex // 读写锁
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager 创建 MemTable 管理器
|
// NewMemTableManager 创建 MemTable 管理器
|
||||||
func NewManager(maxSize int64) *Manager {
|
func NewMemTableManager(maxSize int64) *MemTableManager {
|
||||||
return &Manager{
|
return &MemTableManager{
|
||||||
active: New(),
|
active: NewMemTable(),
|
||||||
immutables: make([]*ImmutableMemTable, 0),
|
immutables: make([]*ImmutableMemTable, 0),
|
||||||
maxSize: maxSize,
|
maxSize: maxSize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActiveWAL 设置 Active MemTable 对应的 WAL 编号
|
// SetActiveWAL 设置 Active MemTable 对应的 WAL 编号
|
||||||
func (m *Manager) SetActiveWAL(walNumber int64) {
|
func (m *MemTableManager) SetActiveWAL(walNumber int64) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
m.activeWAL = walNumber
|
m.activeWAL = walNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put 写入数据到 Active MemTable
|
// Put 写入数据到 Active MemTable
|
||||||
func (m *Manager) Put(key int64, value []byte) {
|
func (m *MemTableManager) Put(key int64, value []byte) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
m.active.Put(key, value)
|
m.active.Put(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get 查询数据(先查 Active,再查 Immutables)
|
// Get 查询数据(先查 Active,再查 Immutables)
|
||||||
func (m *Manager) Get(key int64) ([]byte, bool) {
|
func (m *MemTableManager) Get(key int64) ([]byte, bool) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -63,21 +197,21 @@ func (m *Manager) Get(key int64) ([]byte, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveSize 获取 Active MemTable 大小
|
// GetActiveSize 获取 Active MemTable 大小
|
||||||
func (m *Manager) GetActiveSize() int64 {
|
func (m *MemTableManager) GetActiveSize() int64 {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.active.Size()
|
return m.active.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveCount 获取 Active MemTable 条目数
|
// GetActiveCount 获取 Active MemTable 条目数
|
||||||
func (m *Manager) GetActiveCount() int {
|
func (m *MemTableManager) GetActiveCount() int {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.active.Count()
|
return m.active.Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShouldSwitch 检查是否需要切换 MemTable
|
// ShouldSwitch 检查是否需要切换 MemTable
|
||||||
func (m *Manager) ShouldSwitch() bool {
|
func (m *MemTableManager) ShouldSwitch() bool {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.active.Size() >= m.maxSize
|
return m.active.Size() >= m.maxSize
|
||||||
@@ -85,7 +219,7 @@ func (m *Manager) ShouldSwitch() bool {
|
|||||||
|
|
||||||
// Switch 切换 MemTable(Active → Immutable,创建新 Active)
|
// Switch 切换 MemTable(Active → Immutable,创建新 Active)
|
||||||
// 返回:旧的 WAL 编号,新的 Active MemTable
|
// 返回:旧的 WAL 编号,新的 Active MemTable
|
||||||
func (m *Manager) Switch(newWALNumber int64) (oldWALNumber int64, immutable *ImmutableMemTable) {
|
func (m *MemTableManager) Switch(newWALNumber int64) (oldWALNumber int64, immutable *ImmutableMemTable) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -97,7 +231,7 @@ func (m *Manager) Switch(newWALNumber int64) (oldWALNumber int64, immutable *Imm
|
|||||||
m.immutables = append(m.immutables, immutable)
|
m.immutables = append(m.immutables, immutable)
|
||||||
|
|
||||||
// 2. 创建新的 Active MemTable
|
// 2. 创建新的 Active MemTable
|
||||||
m.active = New()
|
m.active = NewMemTable()
|
||||||
oldWALNumber = m.activeWAL
|
oldWALNumber = m.activeWAL
|
||||||
m.activeWAL = newWALNumber
|
m.activeWAL = newWALNumber
|
||||||
|
|
||||||
@@ -105,7 +239,7 @@ func (m *Manager) Switch(newWALNumber int64) (oldWALNumber int64, immutable *Imm
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImmutable 移除指定的 Immutable MemTable
|
// RemoveImmutable 移除指定的 Immutable MemTable
|
||||||
func (m *Manager) RemoveImmutable(target *ImmutableMemTable) {
|
func (m *MemTableManager) RemoveImmutable(target *ImmutableMemTable) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -119,14 +253,14 @@ func (m *Manager) RemoveImmutable(target *ImmutableMemTable) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetImmutableCount 获取 Immutable MemTable 数量
|
// GetImmutableCount 获取 Immutable MemTable 数量
|
||||||
func (m *Manager) GetImmutableCount() int {
|
func (m *MemTableManager) GetImmutableCount() int {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return len(m.immutables)
|
return len(m.immutables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetImmutables 获取所有 Immutable MemTables(副本)
|
// GetImmutables 获取所有 Immutable MemTables(副本)
|
||||||
func (m *Manager) GetImmutables() []*ImmutableMemTable {
|
func (m *MemTableManager) GetImmutables() []*ImmutableMemTable {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -136,14 +270,14 @@ func (m *Manager) GetImmutables() []*ImmutableMemTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActive 获取 Active MemTable(用于 Flush 时读取)
|
// GetActive 获取 Active MemTable(用于 Flush 时读取)
|
||||||
func (m *Manager) GetActive() *MemTable {
|
func (m *MemTableManager) GetActive() *MemTable {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.active
|
return m.active
|
||||||
}
|
}
|
||||||
|
|
||||||
// TotalCount 获取总条目数(Active + Immutables)
|
// TotalCount 获取总条目数(Active + Immutables)
|
||||||
func (m *Manager) TotalCount() int {
|
func (m *MemTableManager) TotalCount() int {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -155,7 +289,7 @@ func (m *Manager) TotalCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TotalSize 获取总大小(Active + Immutables)
|
// TotalSize 获取总大小(Active + Immutables)
|
||||||
func (m *Manager) TotalSize() int64 {
|
func (m *MemTableManager) TotalSize() int64 {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -167,14 +301,14 @@ func (m *Manager) TotalSize() int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewIterator 创建 Active MemTable 的迭代器
|
// NewIterator 创建 Active MemTable 的迭代器
|
||||||
func (m *Manager) NewIterator() *Iterator {
|
func (m *MemTableManager) NewIterator() *MemTableIterator {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.active.NewIterator()
|
return m.active.NewIterator()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats 统计信息
|
// MemTableStats 统计信息
|
||||||
type Stats struct {
|
type MemTableStats struct {
|
||||||
ActiveSize int64
|
ActiveSize int64
|
||||||
ActiveCount int
|
ActiveCount int
|
||||||
ImmutableCount int
|
ImmutableCount int
|
||||||
@@ -185,11 +319,11 @@ type Stats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetStats 获取统计信息
|
// GetStats 获取统计信息
|
||||||
func (m *Manager) GetStats() *Stats {
|
func (m *MemTableManager) GetStats() *MemTableStats {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
stats := &Stats{
|
stats := &MemTableStats{
|
||||||
ActiveSize: m.active.Size(),
|
ActiveSize: m.active.Size(),
|
||||||
ActiveCount: m.active.Count(),
|
ActiveCount: m.active.Count(),
|
||||||
ImmutableCount: len(m.immutables),
|
ImmutableCount: len(m.immutables),
|
||||||
@@ -207,10 +341,10 @@ func (m *Manager) GetStats() *Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear 清空所有 MemTables(用于测试)
|
// Clear 清空所有 MemTables(用于测试)
|
||||||
func (m *Manager) Clear() {
|
func (m *MemTableManager) Clear() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
m.active = New()
|
m.active = NewMemTable()
|
||||||
m.immutables = make([]*ImmutableMemTable, 0)
|
m.immutables = make([]*ImmutableMemTable, 0)
|
||||||
}
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package memtable
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MemTable 内存表
|
|
||||||
type MemTable struct {
|
|
||||||
data map[int64][]byte // key -> value
|
|
||||||
keys []int64 // 排序的 keys
|
|
||||||
size int64 // 数据大小
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// New 创建 MemTable
|
|
||||||
func New() *MemTable {
|
|
||||||
return &MemTable{
|
|
||||||
data: make(map[int64][]byte),
|
|
||||||
keys: make([]int64, 0),
|
|
||||||
size: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put 插入数据
|
|
||||||
func (m *MemTable) Put(key int64, value []byte) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
// 检查是否已存在
|
|
||||||
if _, exists := m.data[key]; !exists {
|
|
||||||
m.keys = append(m.keys, key)
|
|
||||||
// 保持 keys 有序
|
|
||||||
sort.Slice(m.keys, func(i, j int) bool {
|
|
||||||
return m.keys[i] < m.keys[j]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.data[key] = value
|
|
||||||
m.size += int64(len(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 查询数据
|
|
||||||
func (m *MemTable) Get(key int64) ([]byte, bool) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
value, exists := m.data[key]
|
|
||||||
return value, exists
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size 获取大小
|
|
||||||
func (m *MemTable) Size() int64 {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return m.size
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count 获取条目数量
|
|
||||||
func (m *MemTable) Count() int {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return len(m.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys 获取所有 keys 的副本(已排序)
|
|
||||||
func (m *MemTable) Keys() []int64 {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
// 返回副本以避免并发问题
|
|
||||||
keysCopy := make([]int64, len(m.keys))
|
|
||||||
copy(keysCopy, m.keys)
|
|
||||||
return keysCopy
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterator 迭代器
|
|
||||||
type Iterator struct {
|
|
||||||
mt *MemTable
|
|
||||||
index int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIterator 创建迭代器
|
|
||||||
func (m *MemTable) NewIterator() *Iterator {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return &Iterator{
|
|
||||||
mt: m,
|
|
||||||
index: -1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next 移动到下一个
|
|
||||||
func (it *Iterator) Next() bool {
|
|
||||||
it.mt.mu.RLock()
|
|
||||||
defer it.mt.mu.RUnlock()
|
|
||||||
|
|
||||||
it.index++
|
|
||||||
return it.index < len(it.mt.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key 当前 key
|
|
||||||
func (it *Iterator) Key() int64 {
|
|
||||||
it.mt.mu.RLock()
|
|
||||||
defer it.mt.mu.RUnlock()
|
|
||||||
|
|
||||||
if it.index < 0 || it.index >= len(it.mt.keys) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return it.mt.keys[it.index]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value 当前 value
|
|
||||||
func (it *Iterator) Value() []byte {
|
|
||||||
it.mt.mu.RLock()
|
|
||||||
defer it.mt.mu.RUnlock()
|
|
||||||
|
|
||||||
if it.index < 0 || it.index >= len(it.mt.keys) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
key := it.mt.keys[it.index]
|
|
||||||
return it.mt.data[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset 重置迭代器
|
|
||||||
func (it *Iterator) Reset() {
|
|
||||||
it.index = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear 清空 MemTable
|
|
||||||
func (m *MemTable) Clear() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
m.data = make(map[int64][]byte)
|
|
||||||
m.keys = make([]int64, 0)
|
|
||||||
m.size = 0
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package memtable
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMemTable(t *testing.T) {
|
|
||||||
mt := New()
|
|
||||||
|
|
||||||
// 1. 插入数据
|
|
||||||
for i := int64(1); i <= 100; i++ {
|
|
||||||
mt.Put(i, []byte("value_"+string(rune(i))))
|
|
||||||
}
|
|
||||||
|
|
||||||
if mt.Count() != 100 {
|
|
||||||
t.Errorf("Expected 100 entries, got %d", mt.Count())
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Inserted 100 entries, size: %d bytes", mt.Size())
|
|
||||||
|
|
||||||
// 2. 查询数据
|
|
||||||
for i := int64(1); i <= 100; i++ {
|
|
||||||
value, exists := mt.Get(i)
|
|
||||||
if !exists {
|
|
||||||
t.Errorf("Key %d not found", i)
|
|
||||||
}
|
|
||||||
if value == nil {
|
|
||||||
t.Errorf("Key %d: value is nil", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 查询不存在的 key
|
|
||||||
_, exists := mt.Get(101)
|
|
||||||
if exists {
|
|
||||||
t.Error("Key 101 should not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("All tests passed!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemTableIterator(t *testing.T) {
|
|
||||||
mt := New()
|
|
||||||
|
|
||||||
// 插入数据 (乱序)
|
|
||||||
keys := []int64{5, 2, 8, 1, 9, 3, 7, 4, 6, 10}
|
|
||||||
for _, key := range keys {
|
|
||||||
mt.Put(key, []byte("value"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 迭代器应该按顺序返回
|
|
||||||
iter := mt.NewIterator()
|
|
||||||
var result []int64
|
|
||||||
for iter.Next() {
|
|
||||||
result = append(result, iter.Key())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证顺序
|
|
||||||
for i := 0; i < len(result)-1; i++ {
|
|
||||||
if result[i] >= result[i+1] {
|
|
||||||
t.Errorf("Keys not in order: %v", result)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result) != 10 {
|
|
||||||
t.Errorf("Expected 10 keys, got %d", len(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Iterator returned keys in order: %v", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemTableClear(t *testing.T) {
|
|
||||||
mt := New()
|
|
||||||
|
|
||||||
// 插入数据
|
|
||||||
for i := int64(1); i <= 10; i++ {
|
|
||||||
mt.Put(i, []byte("value"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if mt.Count() != 10 {
|
|
||||||
t.Errorf("Expected 10 entries, got %d", mt.Count())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空
|
|
||||||
mt.Clear()
|
|
||||||
|
|
||||||
if mt.Count() != 0 {
|
|
||||||
t.Errorf("Expected 0 entries after clear, got %d", mt.Count())
|
|
||||||
}
|
|
||||||
|
|
||||||
if mt.Size() != 0 {
|
|
||||||
t.Errorf("Expected size 0 after clear, got %d", mt.Size())
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("Clear test passed!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkMemTablePut(b *testing.B) {
|
|
||||||
mt := New()
|
|
||||||
value := make([]byte, 100)
|
|
||||||
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
mt.Put(int64(i), value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkMemTableGet(b *testing.B) {
|
|
||||||
mt := New()
|
|
||||||
value := make([]byte, 100)
|
|
||||||
|
|
||||||
// 预先插入数据
|
|
||||||
for i := int64(0); i < 10000; i++ {
|
|
||||||
mt.Put(i, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
mt.Get(int64(i % 10000))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,125 @@
|
|||||||
package memtable
|
package srdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestManagerBasic(t *testing.T) {
|
func TestMemTable(t *testing.T) {
|
||||||
mgr := NewManager(1024) // 1KB
|
mt := NewMemTable()
|
||||||
|
|
||||||
|
// 1. 插入数据
|
||||||
|
for i := int64(1); i <= 100; i++ {
|
||||||
|
mt.Put(i, []byte("value_"+string(rune(i))))
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt.Count() != 100 {
|
||||||
|
t.Errorf("Expected 100 entries, got %d", mt.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Inserted 100 entries, size: %d bytes", mt.Size())
|
||||||
|
|
||||||
|
// 2. 查询数据
|
||||||
|
for i := int64(1); i <= 100; i++ {
|
||||||
|
value, exists := mt.Get(i)
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("Key %d not found", i)
|
||||||
|
}
|
||||||
|
if value == nil {
|
||||||
|
t.Errorf("Key %d: value is nil", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查询不存在的 key
|
||||||
|
_, exists := mt.Get(101)
|
||||||
|
if exists {
|
||||||
|
t.Error("Key 101 should not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("All tests passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableIterator(t *testing.T) {
|
||||||
|
mt := NewMemTable()
|
||||||
|
|
||||||
|
// 插入数据 (乱序)
|
||||||
|
keys := []int64{5, 2, 8, 1, 9, 3, 7, 4, 6, 10}
|
||||||
|
for _, key := range keys {
|
||||||
|
mt.Put(key, []byte("value"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迭代器应该按顺序返回
|
||||||
|
iter := mt.NewIterator()
|
||||||
|
var result []int64
|
||||||
|
for iter.Next() {
|
||||||
|
result = append(result, iter.Key())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证顺序
|
||||||
|
for i := 0; i < len(result)-1; i++ {
|
||||||
|
if result[i] >= result[i+1] {
|
||||||
|
t.Errorf("Keys not in order: %v", result)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) != 10 {
|
||||||
|
t.Errorf("Expected 10 keys, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Iterator returned keys in order: %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableClear(t *testing.T) {
|
||||||
|
mt := NewMemTable()
|
||||||
|
|
||||||
|
// 插入数据
|
||||||
|
for i := int64(1); i <= 10; i++ {
|
||||||
|
mt.Put(i, []byte("value"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt.Count() != 10 {
|
||||||
|
t.Errorf("Expected 10 entries, got %d", mt.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空
|
||||||
|
mt.Clear()
|
||||||
|
|
||||||
|
if mt.Count() != 0 {
|
||||||
|
t.Errorf("Expected 0 entries after clear, got %d", mt.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
if mt.Size() != 0 {
|
||||||
|
t.Errorf("Expected size 0 after clear, got %d", mt.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Clear test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkMemTablePut(b *testing.B) {
|
||||||
|
mt := NewMemTable()
|
||||||
|
value := make([]byte, 100)
|
||||||
|
|
||||||
|
for i := 0; b.Loop(); i++ {
|
||||||
|
mt.Put(int64(i), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkMemTableGet(b *testing.B) {
|
||||||
|
mt := NewMemTable()
|
||||||
|
value := make([]byte, 100)
|
||||||
|
|
||||||
|
// 预先插入数据
|
||||||
|
for i := range int64(10000) {
|
||||||
|
mt.Put(i, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; b.Loop(); i++ {
|
||||||
|
mt.Get(int64(i % 10000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableManagerBasic(t *testing.T) {
|
||||||
|
mgr := NewMemTableManager(1024) // 1KB
|
||||||
|
|
||||||
// 测试写入
|
// 测试写入
|
||||||
mgr.Put(1, []byte("value1"))
|
mgr.Put(1, []byte("value1"))
|
||||||
@@ -26,8 +140,8 @@ func TestManagerBasic(t *testing.T) {
|
|||||||
t.Log("Manager basic test passed!")
|
t.Log("Manager basic test passed!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerSwitch(t *testing.T) {
|
func TestMemTableManagerSwitch(t *testing.T) {
|
||||||
mgr := NewManager(50) // 50 bytes
|
mgr := NewMemTableManager(50) // 50 bytes
|
||||||
mgr.SetActiveWAL(1)
|
mgr.SetActiveWAL(1)
|
||||||
|
|
||||||
// 写入数据
|
// 写入数据
|
||||||
@@ -69,8 +183,8 @@ func TestManagerSwitch(t *testing.T) {
|
|||||||
t.Log("Manager switch test passed!")
|
t.Log("Manager switch test passed!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerMultipleImmutables(t *testing.T) {
|
func TestMemTableManagerMultipleImmutables(t *testing.T) {
|
||||||
mgr := NewManager(50)
|
mgr := NewMemTableManager(50)
|
||||||
mgr.SetActiveWAL(1)
|
mgr.SetActiveWAL(1)
|
||||||
|
|
||||||
// 第一批数据
|
// 第一批数据
|
||||||
@@ -100,8 +214,8 @@ func TestManagerMultipleImmutables(t *testing.T) {
|
|||||||
t.Log("Manager multiple immutables test passed!")
|
t.Log("Manager multiple immutables test passed!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerRemoveImmutable(t *testing.T) {
|
func TestMemTableManagerRemoveImmutable(t *testing.T) {
|
||||||
mgr := NewManager(50)
|
mgr := NewMemTableManager(50)
|
||||||
mgr.SetActiveWAL(1)
|
mgr.SetActiveWAL(1)
|
||||||
|
|
||||||
// 创建 Immutable
|
// 创建 Immutable
|
||||||
@@ -124,8 +238,8 @@ func TestManagerRemoveImmutable(t *testing.T) {
|
|||||||
t.Log("Manager remove immutable test passed!")
|
t.Log("Manager remove immutable test passed!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerStats(t *testing.T) {
|
func TestMemTableManagerStats(t *testing.T) {
|
||||||
mgr := NewManager(100)
|
mgr := NewMemTableManager(100)
|
||||||
mgr.SetActiveWAL(1)
|
mgr.SetActiveWAL(1)
|
||||||
|
|
||||||
// Active 数据
|
// Active 数据
|
||||||
@@ -161,15 +275,15 @@ func TestManagerStats(t *testing.T) {
|
|||||||
t.Log("Manager stats test passed!")
|
t.Log("Manager stats test passed!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerConcurrent(t *testing.T) {
|
func TestMemTableManagerConcurrent(t *testing.T) {
|
||||||
mgr := NewManager(1024)
|
mgr := NewMemTableManager(1024)
|
||||||
mgr.SetActiveWAL(1)
|
mgr.SetActiveWAL(1)
|
||||||
|
|
||||||
// 并发写入
|
// 并发写入
|
||||||
done := make(chan bool)
|
done := make(chan bool)
|
||||||
for i := 0; i < 10; i++ {
|
for i := range 10 {
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
for j := 0; j < 100; j++ {
|
for j := range 100 {
|
||||||
key := int64(id*100 + j)
|
key := int64(id*100 + j)
|
||||||
mgr.Put(key, []byte("value"))
|
mgr.Put(key, []byte("value"))
|
||||||
}
|
}
|
||||||
@@ -178,7 +292,7 @@ func TestManagerConcurrent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 等待完成
|
// 等待完成
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
125
query.go
125
query.go
@@ -3,9 +3,9 @@ package srdb
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Fieldset interface {
|
type Fieldset interface {
|
||||||
@@ -489,29 +489,101 @@ func (qb *QueryBuilder) Rows() (*Rows, error) {
|
|||||||
visited: make(map[int64]bool),
|
visited: make(map[int64]bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 Active MemTable 迭代器
|
// 收集所有数据源的 keys 并全局排序
|
||||||
|
// 立即读取数据避免 compaction 期间文件被删除
|
||||||
|
keyToRow := make(map[int64]*SSTableRow) // 存储已读取的行数据
|
||||||
|
var allKeys []int64
|
||||||
|
|
||||||
|
// 1. 从 Active MemTable 读取数据
|
||||||
activeMemTable := qb.engine.memtableManager.GetActive()
|
activeMemTable := qb.engine.memtableManager.GetActive()
|
||||||
if activeMemTable != nil {
|
if activeMemTable != nil {
|
||||||
activeKeys := activeMemTable.Keys()
|
activeKeys := activeMemTable.Keys()
|
||||||
if len(activeKeys) > 0 {
|
for _, key := range activeKeys {
|
||||||
rows.memIterator = newMemtableIterator(activeKeys)
|
if data, ok := activeMemTable.Get(key); ok {
|
||||||
|
var row SSTableRow
|
||||||
|
if err := json.Unmarshal(data, &row); err == nil {
|
||||||
|
keyToRow[key] = &row
|
||||||
|
allKeys = append(allKeys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 准备 Immutable MemTables(延迟初始化)
|
// 2. 从所有 Immutable MemTables 读取数据
|
||||||
rows.immutableIndex = 0
|
immutables := qb.engine.memtableManager.GetImmutables()
|
||||||
|
for _, imm := range immutables {
|
||||||
|
immKeys := imm.MemTable.Keys()
|
||||||
|
for _, key := range immKeys {
|
||||||
|
// 如果 key 已存在(来自更新的数据源),跳过
|
||||||
|
if _, exists := keyToRow[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化 SST 文件 readers
|
if data, ok := imm.MemTable.Get(key); ok {
|
||||||
|
var row SSTableRow
|
||||||
|
if err := json.Unmarshal(data, &row); err == nil {
|
||||||
|
keyToRow[key] = &row
|
||||||
|
allKeys = append(allKeys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 收集所有 SST 文件的 keys
|
||||||
sstReaders := qb.engine.sstManager.GetReaders()
|
sstReaders := qb.engine.sstManager.GetReaders()
|
||||||
|
|
||||||
for _, reader := range sstReaders {
|
for _, reader := range sstReaders {
|
||||||
// 获取文件中实际存在的 key 列表(已排序)
|
// 获取文件中实际存在的 key 列表(已在 GetAllKeys 中排序)
|
||||||
// 这比 minKey→maxKey 逐个尝试高效 100-1000 倍(对于稀疏 key)
|
|
||||||
keys := reader.GetAllKeys()
|
keys := reader.GetAllKeys()
|
||||||
rows.sstReaders = append(rows.sstReaders, &sstReader{
|
|
||||||
reader: reader,
|
// 记录所有 keys(实际数据稍后统一从 engine 读取)
|
||||||
keys: keys,
|
for _, key := range keys {
|
||||||
index: 0,
|
// 如果 key 已存在(来自更新的数据源),跳过
|
||||||
})
|
if _, exists := keyToRow[key]; !exists {
|
||||||
|
allKeys = append(allKeys, key)
|
||||||
|
keyToRow[key] = nil // 占位,表示需要读取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 对所有 keys 排序
|
||||||
|
if len(allKeys) > 0 {
|
||||||
|
// 去重(使用 map 已经去重了,但 allKeys 可能有重复)
|
||||||
|
keySet := make(map[int64]bool)
|
||||||
|
uniqueKeys := make([]int64, 0, len(allKeys))
|
||||||
|
for _, key := range allKeys {
|
||||||
|
if !keySet[key] {
|
||||||
|
keySet[key] = true
|
||||||
|
uniqueKeys = append(uniqueKeys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
slices.Sort(uniqueKeys)
|
||||||
|
|
||||||
|
// 统一从 engine 读取所有数据(避免 compaction 导致的文件删除)
|
||||||
|
rows.cachedRows = make([]*SSTableRow, 0, len(uniqueKeys))
|
||||||
|
for _, seq := range uniqueKeys {
|
||||||
|
// 如果已经从 MemTable 读取,直接使用
|
||||||
|
row := keyToRow[seq]
|
||||||
|
if row == nil {
|
||||||
|
// 从 engine 读取(会搜索 MemTable + 所有 SST,包括 compaction 后的新文件)
|
||||||
|
var err error
|
||||||
|
row, err = qb.engine.Get(seq)
|
||||||
|
if err != nil {
|
||||||
|
// 数据不存在(理论上不应该发生,因为 key 来自索引)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if qb.Match(row.Data) {
|
||||||
|
rows.cachedRows = append(rows.cachedRows, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用缓存模式
|
||||||
|
rows.cached = true
|
||||||
|
rows.cachedIndex = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows, nil
|
return rows, nil
|
||||||
@@ -553,7 +625,7 @@ func (qb *QueryBuilder) Scan(value any) error {
|
|||||||
type Row struct {
|
type Row struct {
|
||||||
schema *Schema
|
schema *Schema
|
||||||
fields []string // 要选择的字段,nil 表示选择所有字段
|
fields []string // 要选择的字段,nil 表示选择所有字段
|
||||||
inner *sst.Row
|
inner *SSTableRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data 获取行数据(根据 Select 过滤字段)
|
// Data 获取行数据(根据 Select 过滤字段)
|
||||||
@@ -563,13 +635,11 @@ func (r *Row) Data() map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有指定字段,返回所有数据(包括 _seq 和 _time)
|
// 如果没有指定字段,返回所有数据(包括 _seq 和 _time)
|
||||||
if r.fields == nil || len(r.fields) == 0 {
|
if len(r.fields) == 0 {
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
result["_seq"] = r.inner.Seq
|
result["_seq"] = r.inner.Seq
|
||||||
result["_time"] = r.inner.Time
|
result["_time"] = r.inner.Time
|
||||||
for k, v := range r.inner.Data {
|
maps.Copy(result, r.inner.Data)
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +706,7 @@ type Rows struct {
|
|||||||
|
|
||||||
// 缓存模式(用于 Collect/Data 等方法)
|
// 缓存模式(用于 Collect/Data 等方法)
|
||||||
cached bool
|
cached bool
|
||||||
cachedRows []*sst.Row
|
cachedRows []*SSTableRow
|
||||||
cachedIndex int // 缓存模式下的迭代位置
|
cachedIndex int // 缓存模式下的迭代位置
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +733,6 @@ func (m *memtableIterator) next() (int64, bool) {
|
|||||||
|
|
||||||
// sstReader 包装 SST Reader 的迭代状态
|
// sstReader 包装 SST Reader 的迭代状态
|
||||||
type sstReader struct {
|
type sstReader struct {
|
||||||
reader any // 实际的 SST reader
|
|
||||||
keys []int64 // 文件中实际存在的 key 列表(已排序)
|
keys []int64 // 文件中实际存在的 key 列表(已排序)
|
||||||
index int // 当前迭代位置
|
index int // 当前迭代位置
|
||||||
}
|
}
|
||||||
@@ -769,7 +838,11 @@ func (r *Rows) nextFromCache() bool {
|
|||||||
if r.cachedIndex >= len(r.cachedRows) {
|
if r.cachedIndex >= len(r.cachedRows) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
r.currentRow = &Row{schema: r.schema, fields: r.fields, inner: r.cachedRows[r.cachedIndex]}
|
r.currentRow = &Row{
|
||||||
|
schema: r.schema,
|
||||||
|
fields: r.fields,
|
||||||
|
inner: r.cachedRows[r.cachedIndex],
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,7 +933,11 @@ func (r *Rows) Last() (*Row, error) {
|
|||||||
if len(r.cachedRows) == 0 {
|
if len(r.cachedRows) == 0 {
|
||||||
return nil, fmt.Errorf("no rows")
|
return nil, fmt.Errorf("no rows")
|
||||||
}
|
}
|
||||||
return &Row{schema: r.schema, fields: r.fields, inner: r.cachedRows[len(r.cachedRows)-1]}, nil
|
return &Row{
|
||||||
|
schema: r.schema,
|
||||||
|
fields: r.fields,
|
||||||
|
inner: r.cachedRows[len(r.cachedRows)-1],
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count 返回总行数(别名)
|
// Count 返回总行数(别名)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func TestPredefinedSchemas(t *testing.T) {
|
|||||||
// TestChecksumDeterminism 测试 checksum 的确定性
|
// TestChecksumDeterminism 测试 checksum 的确定性
|
||||||
func TestChecksumDeterminism(t *testing.T) {
|
func TestChecksumDeterminism(t *testing.T) {
|
||||||
// 创建相同的 Schema 多次
|
// 创建相同的 Schema 多次
|
||||||
for i := 0; i < 10; i++ {
|
for i := range 10 {
|
||||||
s1 := NewSchema("users", []Field{
|
s1 := NewSchema("users", []Field{
|
||||||
{Name: "name", Type: FieldTypeString, Indexed: true, Comment: "用户名"},
|
{Name: "name", Type: FieldTypeString, Indexed: true, Comment: "用户名"},
|
||||||
{Name: "age", Type: FieldTypeInt64, Indexed: false, Comment: "年龄"},
|
{Name: "age", Type: FieldTypeInt64, Indexed: false, Comment: "年龄"},
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 二进制编码格式:
|
|
||||||
// [Magic: 4 bytes][Seq: 8 bytes][Time: 8 bytes][DataLen: 4 bytes][Data: variable]
|
|
||||||
|
|
||||||
const (
|
|
||||||
RowMagic = 0x524F5733 // "ROW3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// encodeRowBinary 使用二进制格式编码行数据
|
|
||||||
func encodeRowBinary(row *Row) ([]byte, error) {
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
|
|
||||||
// 写入 Magic Number (用于验证)
|
|
||||||
if err := binary.Write(buf, binary.LittleEndian, uint32(RowMagic)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入 Seq
|
|
||||||
if err := binary.Write(buf, binary.LittleEndian, row.Seq); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入 Time
|
|
||||||
if err := binary.Write(buf, binary.LittleEndian, row.Time); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 序列化用户数据 (仍使用 JSON,但只序列化用户数据部分)
|
|
||||||
dataBytes, err := json.Marshal(row.Data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入数据长度
|
|
||||||
if err := binary.Write(buf, binary.LittleEndian, uint32(len(dataBytes))); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入数据
|
|
||||||
if _, err := buf.Write(dataBytes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeRowBinary 解码二进制格式的行数据
|
|
||||||
func decodeRowBinary(data []byte) (*Row, error) {
|
|
||||||
buf := bytes.NewReader(data)
|
|
||||||
|
|
||||||
// 读取并验证 Magic Number
|
|
||||||
var magic uint32
|
|
||||||
if err := binary.Read(buf, binary.LittleEndian, &magic); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if magic != RowMagic {
|
|
||||||
return nil, fmt.Errorf("invalid row magic: %x", magic)
|
|
||||||
}
|
|
||||||
|
|
||||||
row := &Row{}
|
|
||||||
|
|
||||||
// 读取 Seq
|
|
||||||
if err := binary.Read(buf, binary.LittleEndian, &row.Seq); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 Time
|
|
||||||
if err := binary.Read(buf, binary.LittleEndian, &row.Time); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取数据长度
|
|
||||||
var dataLen uint32
|
|
||||||
if err := binary.Read(buf, binary.LittleEndian, &dataLen); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取数据
|
|
||||||
dataBytes := make([]byte, dataLen)
|
|
||||||
if _, err := buf.Read(dataBytes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 反序列化用户数据
|
|
||||||
if err := json.Unmarshal(dataBytes, &row.Data); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBinaryEncoding(t *testing.T) {
|
|
||||||
// 创建测试数据
|
|
||||||
row := &Row{
|
|
||||||
Seq: 12345,
|
|
||||||
Time: 1234567890,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"name": "test_user",
|
|
||||||
"age": 25,
|
|
||||||
"email": "test@example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编码
|
|
||||||
encoded, err := encodeRowBinary(row)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Encoded size: %d bytes", len(encoded))
|
|
||||||
|
|
||||||
// 解码
|
|
||||||
decoded, err := decodeRowBinary(encoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
if decoded.Seq != row.Seq {
|
|
||||||
t.Errorf("Seq mismatch: expected %d, got %d", row.Seq, decoded.Seq)
|
|
||||||
}
|
|
||||||
if decoded.Time != row.Time {
|
|
||||||
t.Errorf("Time mismatch: expected %d, got %d", row.Time, decoded.Time)
|
|
||||||
}
|
|
||||||
if decoded.Data["name"] != row.Data["name"] {
|
|
||||||
t.Errorf("Name mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("Binary encoding test passed!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEncodingComparison(t *testing.T) {
|
|
||||||
row := &Row{
|
|
||||||
Seq: 12345,
|
|
||||||
Time: 1234567890,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"name": "test_user",
|
|
||||||
"age": 25,
|
|
||||||
"email": "test@example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 二进制编码
|
|
||||||
binaryEncoded, _ := encodeRowBinary(row)
|
|
||||||
|
|
||||||
// JSON 编码 (旧方式)
|
|
||||||
jsonData := map[string]interface{}{
|
|
||||||
"_seq": row.Seq,
|
|
||||||
"_time": row.Time,
|
|
||||||
"data": row.Data,
|
|
||||||
}
|
|
||||||
jsonEncoded, _ := json.Marshal(jsonData)
|
|
||||||
|
|
||||||
t.Logf("Binary size: %d bytes", len(binaryEncoded))
|
|
||||||
t.Logf("JSON size: %d bytes", len(jsonEncoded))
|
|
||||||
t.Logf("Space saved: %.1f%%", float64(len(jsonEncoded)-len(binaryEncoded))/float64(len(jsonEncoded))*100)
|
|
||||||
|
|
||||||
if len(binaryEncoded) >= len(jsonEncoded) {
|
|
||||||
t.Error("Binary encoding should be smaller than JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkBinaryEncoding(b *testing.B) {
|
|
||||||
row := &Row{
|
|
||||||
Seq: 12345,
|
|
||||||
Time: 1234567890,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"name": "test_user",
|
|
||||||
"age": 25,
|
|
||||||
"email": "test@example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
encodeRowBinary(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkJSONEncoding(b *testing.B) {
|
|
||||||
row := &Row{
|
|
||||||
Seq: 12345,
|
|
||||||
Time: 1234567890,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"name": "test_user",
|
|
||||||
"age": 25,
|
|
||||||
"email": "test@example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"_seq": row.Seq,
|
|
||||||
"_time": row.Time,
|
|
||||||
"data": row.Data,
|
|
||||||
}
|
|
||||||
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
json.Marshal(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
142
sst/format.go
142
sst/format.go
@@ -1,142 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// 文件格式
|
|
||||||
MagicNumber = 0x53535433 // "SST3"
|
|
||||||
Version = 1
|
|
||||||
HeaderSize = 256 // 文件头大小
|
|
||||||
BlockSize = 64 * 1024 // 数据块大小 (64 KB)
|
|
||||||
|
|
||||||
// 压缩类型
|
|
||||||
CompressionNone = 0
|
|
||||||
CompressionSnappy = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// Header SST 文件头 (256 bytes)
|
|
||||||
type Header struct {
|
|
||||||
// 基础信息 (32 bytes)
|
|
||||||
Magic uint32 // Magic Number: 0x53535433
|
|
||||||
Version uint32 // 版本号
|
|
||||||
Compression uint8 // 压缩类型
|
|
||||||
Reserved1 [3]byte
|
|
||||||
Flags uint32 // 标志位
|
|
||||||
Reserved2 [16]byte
|
|
||||||
|
|
||||||
// 索引信息 (32 bytes)
|
|
||||||
IndexOffset int64 // B+Tree 索引起始位置
|
|
||||||
IndexSize int64 // B+Tree 索引大小
|
|
||||||
RootOffset int64 // B+Tree 根节点位置
|
|
||||||
Reserved3 [8]byte
|
|
||||||
|
|
||||||
// 数据信息 (32 bytes)
|
|
||||||
DataOffset int64 // 数据块起始位置
|
|
||||||
DataSize int64 // 数据块总大小
|
|
||||||
RowCount int64 // 行数
|
|
||||||
Reserved4 [8]byte
|
|
||||||
|
|
||||||
// 统计信息 (32 bytes)
|
|
||||||
MinKey int64 // 最小 key (_seq)
|
|
||||||
MaxKey int64 // 最大 key (_seq)
|
|
||||||
MinTime int64 // 最小时间戳
|
|
||||||
MaxTime int64 // 最大时间戳
|
|
||||||
|
|
||||||
// CRC 校验 (8 bytes)
|
|
||||||
CRC32 uint32 // Header CRC32
|
|
||||||
Reserved5 [4]byte
|
|
||||||
|
|
||||||
// 预留空间 (120 bytes)
|
|
||||||
Reserved6 [120]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal 序列化 Header
|
|
||||||
func (h *Header) Marshal() []byte {
|
|
||||||
buf := make([]byte, HeaderSize)
|
|
||||||
|
|
||||||
// 基础信息
|
|
||||||
binary.LittleEndian.PutUint32(buf[0:4], h.Magic)
|
|
||||||
binary.LittleEndian.PutUint32(buf[4:8], h.Version)
|
|
||||||
buf[8] = h.Compression
|
|
||||||
copy(buf[9:12], h.Reserved1[:])
|
|
||||||
binary.LittleEndian.PutUint32(buf[12:16], h.Flags)
|
|
||||||
copy(buf[16:32], h.Reserved2[:])
|
|
||||||
|
|
||||||
// 索引信息
|
|
||||||
binary.LittleEndian.PutUint64(buf[32:40], uint64(h.IndexOffset))
|
|
||||||
binary.LittleEndian.PutUint64(buf[40:48], uint64(h.IndexSize))
|
|
||||||
binary.LittleEndian.PutUint64(buf[48:56], uint64(h.RootOffset))
|
|
||||||
copy(buf[56:64], h.Reserved3[:])
|
|
||||||
|
|
||||||
// 数据信息
|
|
||||||
binary.LittleEndian.PutUint64(buf[64:72], uint64(h.DataOffset))
|
|
||||||
binary.LittleEndian.PutUint64(buf[72:80], uint64(h.DataSize))
|
|
||||||
binary.LittleEndian.PutUint64(buf[80:88], uint64(h.RowCount))
|
|
||||||
copy(buf[88:96], h.Reserved4[:])
|
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
binary.LittleEndian.PutUint64(buf[96:104], uint64(h.MinKey))
|
|
||||||
binary.LittleEndian.PutUint64(buf[104:112], uint64(h.MaxKey))
|
|
||||||
binary.LittleEndian.PutUint64(buf[112:120], uint64(h.MinTime))
|
|
||||||
binary.LittleEndian.PutUint64(buf[120:128], uint64(h.MaxTime))
|
|
||||||
|
|
||||||
// CRC 校验
|
|
||||||
binary.LittleEndian.PutUint32(buf[128:132], h.CRC32)
|
|
||||||
copy(buf[132:136], h.Reserved5[:])
|
|
||||||
|
|
||||||
// 预留空间
|
|
||||||
copy(buf[136:256], h.Reserved6[:])
|
|
||||||
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmarshal 反序列化 Header
|
|
||||||
func UnmarshalHeader(data []byte) *Header {
|
|
||||||
if len(data) < HeaderSize {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &Header{}
|
|
||||||
|
|
||||||
// 基础信息
|
|
||||||
h.Magic = binary.LittleEndian.Uint32(data[0:4])
|
|
||||||
h.Version = binary.LittleEndian.Uint32(data[4:8])
|
|
||||||
h.Compression = data[8]
|
|
||||||
copy(h.Reserved1[:], data[9:12])
|
|
||||||
h.Flags = binary.LittleEndian.Uint32(data[12:16])
|
|
||||||
copy(h.Reserved2[:], data[16:32])
|
|
||||||
|
|
||||||
// 索引信息
|
|
||||||
h.IndexOffset = int64(binary.LittleEndian.Uint64(data[32:40]))
|
|
||||||
h.IndexSize = int64(binary.LittleEndian.Uint64(data[40:48]))
|
|
||||||
h.RootOffset = int64(binary.LittleEndian.Uint64(data[48:56]))
|
|
||||||
copy(h.Reserved3[:], data[56:64])
|
|
||||||
|
|
||||||
// 数据信息
|
|
||||||
h.DataOffset = int64(binary.LittleEndian.Uint64(data[64:72]))
|
|
||||||
h.DataSize = int64(binary.LittleEndian.Uint64(data[72:80]))
|
|
||||||
h.RowCount = int64(binary.LittleEndian.Uint64(data[80:88]))
|
|
||||||
copy(h.Reserved4[:], data[88:96])
|
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
h.MinKey = int64(binary.LittleEndian.Uint64(data[96:104]))
|
|
||||||
h.MaxKey = int64(binary.LittleEndian.Uint64(data[104:112]))
|
|
||||||
h.MinTime = int64(binary.LittleEndian.Uint64(data[112:120]))
|
|
||||||
h.MaxTime = int64(binary.LittleEndian.Uint64(data[120:128]))
|
|
||||||
|
|
||||||
// CRC 校验
|
|
||||||
h.CRC32 = binary.LittleEndian.Uint32(data[128:132])
|
|
||||||
copy(h.Reserved5[:], data[132:136])
|
|
||||||
|
|
||||||
// 预留空间
|
|
||||||
copy(h.Reserved6[:], data[136:256])
|
|
||||||
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate 验证 Header
|
|
||||||
func (h *Header) Validate() bool {
|
|
||||||
return h.Magic == MagicNumber && h.Version == Version
|
|
||||||
}
|
|
||||||
284
sst/manager.go
284
sst/manager.go
@@ -1,284 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager SST 文件管理器
|
|
||||||
type Manager struct {
|
|
||||||
dir string
|
|
||||||
readers []*Reader
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager 创建 SST 管理器
|
|
||||||
func NewManager(dir string) (*Manager, error) {
|
|
||||||
// 确保目录存在
|
|
||||||
err := os.MkdirAll(dir, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr := &Manager{
|
|
||||||
dir: dir,
|
|
||||||
readers: make([]*Reader, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复现有的 SST 文件
|
|
||||||
err = mgr.recover()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mgr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recover 恢复现有的 SST 文件
|
|
||||||
func (m *Manager) recover() error {
|
|
||||||
// 查找所有 SST 文件
|
|
||||||
files, err := filepath.Glob(filepath.Join(m.dir, "*.sst"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
// 跳过索引文件
|
|
||||||
filename := filepath.Base(file)
|
|
||||||
if strings.HasPrefix(filename, "idx_") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开 SST Reader
|
|
||||||
reader, err := NewReader(file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.readers = append(m.readers, reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSST 创建新的 SST 文件
|
|
||||||
// fileNumber: 文件编号(由 VersionSet 分配)
|
|
||||||
func (m *Manager) CreateSST(fileNumber int64, rows []*Row) (*Reader, error) {
|
|
||||||
return m.CreateSSTWithLevel(fileNumber, rows, 0) // 默认创建到 L0
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSSTWithLevel 创建新的 SST 文件到指定层级
|
|
||||||
// fileNumber: 文件编号(由 VersionSet 分配)
|
|
||||||
func (m *Manager) CreateSSTWithLevel(fileNumber int64, rows []*Row, level int) (*Reader, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
sstPath := filepath.Join(m.dir, fmt.Sprintf("%06d.sst", fileNumber))
|
|
||||||
|
|
||||||
// 创建文件
|
|
||||||
file, err := os.Create(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
writer := NewWriter(file)
|
|
||||||
|
|
||||||
// 写入所有行
|
|
||||||
for _, row := range rows {
|
|
||||||
err = writer.Add(row)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
os.Remove(sstPath)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 完成写入
|
|
||||||
err = writer.Finish()
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
os.Remove(sstPath)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
// 打开 SST Reader
|
|
||||||
reader, err := NewReader(sstPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到 readers 列表
|
|
||||||
m.readers = append(m.readers, reader)
|
|
||||||
|
|
||||||
return reader, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 从所有 SST 文件中查找数据
|
|
||||||
func (m *Manager) Get(seq int64) (*Row, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
// 从后往前查找(新的文件优先)
|
|
||||||
for i := len(m.readers) - 1; i >= 0; i-- {
|
|
||||||
reader := m.readers[i]
|
|
||||||
row, err := reader.Get(seq)
|
|
||||||
if err == nil {
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("key not found: %d", seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetReaders 获取所有 Readers(用于扫描)
|
|
||||||
func (m *Manager) GetReaders() []*Reader {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
// 返回副本
|
|
||||||
readers := make([]*Reader, len(m.readers))
|
|
||||||
copy(readers, m.readers)
|
|
||||||
return readers
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMaxSeq 获取所有 SST 中的最大 seq
|
|
||||||
func (m *Manager) GetMaxSeq() int64 {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
maxSeq := int64(0)
|
|
||||||
for _, reader := range m.readers {
|
|
||||||
header := reader.GetHeader()
|
|
||||||
if header.MaxKey > maxSeq {
|
|
||||||
maxSeq = header.MaxKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxSeq
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count 获取 SST 文件数量
|
|
||||||
func (m *Manager) Count() int {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return len(m.readers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListFiles 列出所有 SST 文件
|
|
||||||
func (m *Manager) ListFiles() []string {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
files := make([]string, 0, len(m.readers))
|
|
||||||
for _, reader := range m.readers {
|
|
||||||
files = append(files, reader.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompactionConfig Compaction 配置
|
|
||||||
// 已废弃:请使用 compaction 包中的 Manager
|
|
||||||
type CompactionConfig struct {
|
|
||||||
Threshold int // 触发阈值(SST 文件数量)
|
|
||||||
BatchSize int // 每次合并的文件数量
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultCompactionConfig 默认配置
|
|
||||||
// 已废弃:请使用 compaction 包中的 Manager
|
|
||||||
var DefaultCompactionConfig = CompactionConfig{
|
|
||||||
Threshold: 10,
|
|
||||||
BatchSize: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShouldCompact 检查是否需要 Compaction
|
|
||||||
// 已废弃:请使用 compaction 包中的 Manager
|
|
||||||
func (m *Manager) ShouldCompact(config CompactionConfig) bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return len(m.readers) > config.Threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact 执行 Compaction
|
|
||||||
// 已废弃:请使用 compaction 包中的 Manager
|
|
||||||
// 注意:此方法已不再维护,不应在新代码中使用
|
|
||||||
func (m *Manager) Compact(config CompactionConfig) error {
|
|
||||||
// 此方法已废弃,不再实现
|
|
||||||
return fmt.Errorf("Compact is deprecated, please use compaction.Manager")
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortRows 按 seq 排序
|
|
||||||
func sortRows(rows []*Row) {
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
return rows[i].Seq < rows[j].Seq
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 删除指定的 SST 文件(预留接口)
|
|
||||||
func (m *Manager) Delete(fileNumber int64) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
sstPath := filepath.Join(m.dir, fmt.Sprintf("%06d.sst", fileNumber))
|
|
||||||
return os.Remove(sstPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭所有 SST Readers
|
|
||||||
func (m *Manager) Close() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
for _, reader := range m.readers {
|
|
||||||
reader.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
m.readers = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats 统计信息
|
|
||||||
type Stats struct {
|
|
||||||
FileCount int
|
|
||||||
TotalSize int64
|
|
||||||
MinSeq int64
|
|
||||||
MaxSeq int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats 获取统计信息
|
|
||||||
func (m *Manager) GetStats() *Stats {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
stats := &Stats{
|
|
||||||
FileCount: len(m.readers),
|
|
||||||
MinSeq: -1,
|
|
||||||
MaxSeq: -1,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, reader := range m.readers {
|
|
||||||
header := reader.GetHeader()
|
|
||||||
|
|
||||||
if stats.MinSeq == -1 || header.MinKey < stats.MinSeq {
|
|
||||||
stats.MinSeq = header.MinKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats.MaxSeq == -1 || header.MaxKey > stats.MaxSeq {
|
|
||||||
stats.MaxSeq = header.MaxKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取文件大小
|
|
||||||
if stat, err := os.Stat(reader.path); err == nil {
|
|
||||||
stats.TotalSize += stat.Size()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
152
sst/reader.go
152
sst/reader.go
@@ -1,152 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/btree"
|
|
||||||
"github.com/edsrzf/mmap-go"
|
|
||||||
"github.com/golang/snappy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reader SST 文件读取器
|
|
||||||
type Reader struct {
|
|
||||||
path string
|
|
||||||
file *os.File
|
|
||||||
mmap mmap.MMap
|
|
||||||
header *Header
|
|
||||||
btReader *btree.Reader
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReader 创建 SST 读取器
|
|
||||||
func NewReader(path string) (*Reader, error) {
|
|
||||||
// 1. 打开文件
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. mmap 映射
|
|
||||||
mmapData, err := mmap.Map(file, mmap.RDONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 读取 Header
|
|
||||||
if len(mmapData) < HeaderSize {
|
|
||||||
mmapData.Unmap()
|
|
||||||
file.Close()
|
|
||||||
return nil, fmt.Errorf("file too small")
|
|
||||||
}
|
|
||||||
|
|
||||||
header := UnmarshalHeader(mmapData[:HeaderSize])
|
|
||||||
if header == nil || !header.Validate() {
|
|
||||||
mmapData.Unmap()
|
|
||||||
file.Close()
|
|
||||||
return nil, fmt.Errorf("invalid header")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 创建 B+Tree Reader
|
|
||||||
btReader := btree.NewReader(mmapData, header.RootOffset)
|
|
||||||
|
|
||||||
return &Reader{
|
|
||||||
path: path,
|
|
||||||
file: file,
|
|
||||||
mmap: mmapData,
|
|
||||||
header: header,
|
|
||||||
btReader: btReader,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 查询一行数据
|
|
||||||
func (r *Reader) Get(key int64) (*Row, error) {
|
|
||||||
// 1. 检查范围
|
|
||||||
if key < r.header.MinKey || key > r.header.MaxKey {
|
|
||||||
return nil, fmt.Errorf("key out of range")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 在 B+Tree 中查找
|
|
||||||
dataOffset, dataSize, found := r.btReader.Get(key)
|
|
||||||
if !found {
|
|
||||||
return nil, fmt.Errorf("key not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 读取数据
|
|
||||||
if dataOffset+int64(dataSize) > int64(len(r.mmap)) {
|
|
||||||
return nil, fmt.Errorf("invalid data offset")
|
|
||||||
}
|
|
||||||
|
|
||||||
compressed := r.mmap[dataOffset : dataOffset+int64(dataSize)]
|
|
||||||
|
|
||||||
// 4. 解压缩
|
|
||||||
var data []byte
|
|
||||||
var err error
|
|
||||||
if r.header.Compression == CompressionSnappy {
|
|
||||||
data, err = snappy.Decode(nil, compressed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = compressed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 反序列化
|
|
||||||
row, err := decodeRow(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHeader 获取文件头信息
|
|
||||||
func (r *Reader) GetHeader() *Header {
|
|
||||||
return r.header
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPath 获取文件路径
|
|
||||||
func (r *Reader) GetPath() string {
|
|
||||||
return r.path
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllKeys 获取文件中所有的 key(按顺序)
|
|
||||||
func (r *Reader) GetAllKeys() []int64 {
|
|
||||||
return r.btReader.GetAllKeys()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭读取器
|
|
||||||
func (r *Reader) Close() error {
|
|
||||||
if r.mmap != nil {
|
|
||||||
r.mmap.Unmap()
|
|
||||||
}
|
|
||||||
if r.file != nil {
|
|
||||||
return r.file.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeRow 解码行数据
|
|
||||||
func decodeRow(data []byte) (*Row, error) {
|
|
||||||
// 尝试使用二进制格式解码
|
|
||||||
row, err := decodeRowBinary(data)
|
|
||||||
if err == nil {
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 降级到 JSON (兼容旧数据)
|
|
||||||
var decoded map[string]interface{}
|
|
||||||
err = json.Unmarshal(data, &decoded)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
row = &Row{
|
|
||||||
Seq: int64(decoded["_seq"].(float64)),
|
|
||||||
Time: int64(decoded["_time"].(float64)),
|
|
||||||
Data: decoded["data"].(map[string]interface{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
183
sst/sst_test.go
183
sst/sst_test.go
@@ -1,183 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSST(t *testing.T) {
|
|
||||||
// 1. 创建测试文件
|
|
||||||
file, err := os.Create("test.sst")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer os.Remove("test.sst")
|
|
||||||
|
|
||||||
// 2. 写入数据
|
|
||||||
writer := NewWriter(file)
|
|
||||||
|
|
||||||
// 添加 1000 行数据
|
|
||||||
for i := int64(1); i <= 1000; i++ {
|
|
||||||
row := &Row{
|
|
||||||
Seq: i,
|
|
||||||
Time: 1000000 + i,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"name": "user_" + string(rune(i)),
|
|
||||||
"age": 20 + i%50,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err := writer.Add(row)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 完成写入
|
|
||||||
err = writer.Finish()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
t.Logf("Written 1000 rows")
|
|
||||||
|
|
||||||
// 3. 读取数据
|
|
||||||
reader, err := NewReader("test.sst")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
// 验证 Header
|
|
||||||
header := reader.GetHeader()
|
|
||||||
if header.RowCount != 1000 {
|
|
||||||
t.Errorf("Expected 1000 rows, got %d", header.RowCount)
|
|
||||||
}
|
|
||||||
if header.MinKey != 1 {
|
|
||||||
t.Errorf("Expected MinKey=1, got %d", header.MinKey)
|
|
||||||
}
|
|
||||||
if header.MaxKey != 1000 {
|
|
||||||
t.Errorf("Expected MaxKey=1000, got %d", header.MaxKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Header: RowCount=%d, MinKey=%d, MaxKey=%d",
|
|
||||||
header.RowCount, header.MinKey, header.MaxKey)
|
|
||||||
|
|
||||||
// 4. 查询测试
|
|
||||||
for i := int64(1); i <= 1000; i++ {
|
|
||||||
row, err := reader.Get(i)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to get key %d: %v", i, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if row.Seq != i {
|
|
||||||
t.Errorf("Key %d: expected Seq=%d, got %d", i, i, row.Seq)
|
|
||||||
}
|
|
||||||
if row.Time != 1000000+i {
|
|
||||||
t.Errorf("Key %d: expected Time=%d, got %d", i, 1000000+i, row.Time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试不存在的 key
|
|
||||||
_, err = reader.Get(1001)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Key 1001 should not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = reader.Get(0)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Key 0 should not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("All tests passed!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHeaderSerialization(t *testing.T) {
|
|
||||||
// 创建 Header
|
|
||||||
header := &Header{
|
|
||||||
Magic: MagicNumber,
|
|
||||||
Version: Version,
|
|
||||||
Compression: CompressionSnappy,
|
|
||||||
IndexOffset: 256,
|
|
||||||
IndexSize: 1024,
|
|
||||||
RootOffset: 512,
|
|
||||||
DataOffset: 2048,
|
|
||||||
DataSize: 10240,
|
|
||||||
RowCount: 100,
|
|
||||||
MinKey: 1,
|
|
||||||
MaxKey: 100,
|
|
||||||
MinTime: 1000000,
|
|
||||||
MaxTime: 1000100,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 序列化
|
|
||||||
data := header.Marshal()
|
|
||||||
if len(data) != HeaderSize {
|
|
||||||
t.Errorf("Expected size %d, got %d", HeaderSize, len(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 反序列化
|
|
||||||
header2 := UnmarshalHeader(data)
|
|
||||||
if header2 == nil {
|
|
||||||
t.Fatal("Unmarshal failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
if header2.Magic != header.Magic {
|
|
||||||
t.Error("Magic mismatch")
|
|
||||||
}
|
|
||||||
if header2.Version != header.Version {
|
|
||||||
t.Error("Version mismatch")
|
|
||||||
}
|
|
||||||
if header2.Compression != header.Compression {
|
|
||||||
t.Error("Compression mismatch")
|
|
||||||
}
|
|
||||||
if header2.RowCount != header.RowCount {
|
|
||||||
t.Error("RowCount mismatch")
|
|
||||||
}
|
|
||||||
if header2.MinKey != header.MinKey {
|
|
||||||
t.Error("MinKey mismatch")
|
|
||||||
}
|
|
||||||
if header2.MaxKey != header.MaxKey {
|
|
||||||
t.Error("MaxKey mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
if !header2.Validate() {
|
|
||||||
t.Error("Header validation failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("Header serialization test passed!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkSSTGet(b *testing.B) {
|
|
||||||
// 创建测试文件
|
|
||||||
file, _ := os.Create("bench.sst")
|
|
||||||
defer os.Remove("bench.sst")
|
|
||||||
|
|
||||||
writer := NewWriter(file)
|
|
||||||
for i := int64(1); i <= 10000; i++ {
|
|
||||||
row := &Row{
|
|
||||||
Seq: i,
|
|
||||||
Time: 1000000 + i,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"value": i,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
writer.Add(row)
|
|
||||||
}
|
|
||||||
writer.Finish()
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
// 打开读取器
|
|
||||||
reader, _ := NewReader("bench.sst")
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
// 性能测试
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
key := int64(i%10000 + 1)
|
|
||||||
reader.Get(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
155
sst/writer.go
155
sst/writer.go
@@ -1,155 +0,0 @@
|
|||||||
package sst
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/btree"
|
|
||||||
"github.com/golang/snappy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Writer SST 文件写入器
|
|
||||||
type Writer struct {
|
|
||||||
file *os.File
|
|
||||||
builder *btree.Builder
|
|
||||||
dataOffset int64
|
|
||||||
dataStart int64 // 数据起始位置
|
|
||||||
rowCount int64
|
|
||||||
minKey int64
|
|
||||||
maxKey int64
|
|
||||||
minTime int64
|
|
||||||
maxTime int64
|
|
||||||
compression uint8
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWriter 创建 SST 写入器
|
|
||||||
func NewWriter(file *os.File) *Writer {
|
|
||||||
return &Writer{
|
|
||||||
file: file,
|
|
||||||
builder: btree.NewBuilder(file, HeaderSize),
|
|
||||||
dataOffset: 0, // 先写数据,后面会更新
|
|
||||||
compression: CompressionSnappy,
|
|
||||||
minKey: -1,
|
|
||||||
maxKey: -1,
|
|
||||||
minTime: -1,
|
|
||||||
maxTime: -1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 表示一行数据
|
|
||||||
type Row struct {
|
|
||||||
Seq int64 // _seq
|
|
||||||
Time int64 // _time
|
|
||||||
Data map[string]any // 用户数据
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add 添加一行数据
|
|
||||||
func (w *Writer) Add(row *Row) error {
|
|
||||||
// 更新统计信息
|
|
||||||
if w.minKey == -1 || row.Seq < w.minKey {
|
|
||||||
w.minKey = row.Seq
|
|
||||||
}
|
|
||||||
if w.maxKey == -1 || row.Seq > w.maxKey {
|
|
||||||
w.maxKey = row.Seq
|
|
||||||
}
|
|
||||||
if w.minTime == -1 || row.Time < w.minTime {
|
|
||||||
w.minTime = row.Time
|
|
||||||
}
|
|
||||||
if w.maxTime == -1 || row.Time > w.maxTime {
|
|
||||||
w.maxTime = row.Time
|
|
||||||
}
|
|
||||||
w.rowCount++
|
|
||||||
|
|
||||||
// 序列化数据 (简单的 JSON 序列化,后续可以优化)
|
|
||||||
data := encodeRow(row)
|
|
||||||
|
|
||||||
// 压缩数据
|
|
||||||
var compressed []byte
|
|
||||||
if w.compression == CompressionSnappy {
|
|
||||||
compressed = snappy.Encode(nil, data)
|
|
||||||
} else {
|
|
||||||
compressed = data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入数据块
|
|
||||||
// 第一次写入时,确定数据起始位置
|
|
||||||
if w.dataStart == 0 {
|
|
||||||
// 预留足够空间给 B+Tree 索引
|
|
||||||
// 假设索引最多占用 10% 的空间,最少 1 MB
|
|
||||||
estimatedIndexSize := int64(10 * 1024 * 1024) // 10 MB
|
|
||||||
w.dataStart = HeaderSize + estimatedIndexSize
|
|
||||||
w.dataOffset = w.dataStart
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := w.dataOffset
|
|
||||||
_, err := w.file.WriteAt(compressed, offset)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到 B+Tree
|
|
||||||
err = w.builder.Add(row.Seq, offset, int32(len(compressed)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新数据偏移
|
|
||||||
w.dataOffset += int64(len(compressed))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish 完成写入
|
|
||||||
func (w *Writer) Finish() error {
|
|
||||||
// 1. 构建 B+Tree 索引
|
|
||||||
rootOffset, err := w.builder.Build()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 计算索引大小
|
|
||||||
indexSize := w.dataStart - HeaderSize
|
|
||||||
|
|
||||||
// 3. 创建 Header
|
|
||||||
header := &Header{
|
|
||||||
Magic: MagicNumber,
|
|
||||||
Version: Version,
|
|
||||||
Compression: w.compression,
|
|
||||||
IndexOffset: HeaderSize,
|
|
||||||
IndexSize: indexSize,
|
|
||||||
RootOffset: rootOffset,
|
|
||||||
DataOffset: w.dataStart,
|
|
||||||
DataSize: w.dataOffset - w.dataStart,
|
|
||||||
RowCount: w.rowCount,
|
|
||||||
MinKey: w.minKey,
|
|
||||||
MaxKey: w.maxKey,
|
|
||||||
MinTime: w.minTime,
|
|
||||||
MaxTime: w.maxTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 写入 Header
|
|
||||||
headerData := header.Marshal()
|
|
||||||
_, err = w.file.WriteAt(headerData, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Sync 到磁盘
|
|
||||||
return w.file.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeRow 编码行数据 (使用二进制格式)
|
|
||||||
func encodeRow(row *Row) []byte {
|
|
||||||
// 使用二进制格式编码
|
|
||||||
encoded, err := encodeRowBinary(row)
|
|
||||||
if err != nil {
|
|
||||||
// 降级到 JSON (不应该发生)
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"_seq": row.Seq,
|
|
||||||
"_time": row.Time,
|
|
||||||
"data": row.Data,
|
|
||||||
}
|
|
||||||
encoded, _ = json.Marshal(data)
|
|
||||||
}
|
|
||||||
return encoded
|
|
||||||
}
|
|
||||||
1134
sstable.go
Normal file
1134
sstable.go
Normal file
File diff suppressed because it is too large
Load Diff
466
sstable_test.go
Normal file
466
sstable_test.go
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
package srdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSTable(t *testing.T) {
|
||||||
|
// 1. 创建测试文件
|
||||||
|
file, err := os.Create("test.sst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove("test.sst")
|
||||||
|
|
||||||
|
// 2. 写入数据
|
||||||
|
writer := NewSSTableWriter(file, nil)
|
||||||
|
|
||||||
|
// 添加 1000 行数据
|
||||||
|
for i := int64(1); i <= 1000; i++ {
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: i,
|
||||||
|
Time: 1000000 + i,
|
||||||
|
Data: map[string]any{
|
||||||
|
"name": "user_" + string(rune(i)),
|
||||||
|
"age": 20 + i%50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := writer.Add(row)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成写入
|
||||||
|
err = writer.Finish()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
t.Logf("Written 1000 rows")
|
||||||
|
|
||||||
|
// 3. 读取数据
|
||||||
|
reader, err := NewSSTableReader("test.sst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// 验证 Header
|
||||||
|
header := reader.GetHeader()
|
||||||
|
if header.RowCount != 1000 {
|
||||||
|
t.Errorf("Expected 1000 rows, got %d", header.RowCount)
|
||||||
|
}
|
||||||
|
if header.MinKey != 1 {
|
||||||
|
t.Errorf("Expected MinKey=1, got %d", header.MinKey)
|
||||||
|
}
|
||||||
|
if header.MaxKey != 1000 {
|
||||||
|
t.Errorf("Expected MaxKey=1000, got %d", header.MaxKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Header: RowCount=%d, MinKey=%d, MaxKey=%d",
|
||||||
|
header.RowCount, header.MinKey, header.MaxKey)
|
||||||
|
|
||||||
|
// 4. 查询测试
|
||||||
|
for i := int64(1); i <= 1000; i++ {
|
||||||
|
row, err := reader.Get(i)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get key %d: %v", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.Seq != i {
|
||||||
|
t.Errorf("Key %d: expected Seq=%d, got %d", i, i, row.Seq)
|
||||||
|
}
|
||||||
|
if row.Time != 1000000+i {
|
||||||
|
t.Errorf("Key %d: expected Time=%d, got %d", i, 1000000+i, row.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试不存在的 key
|
||||||
|
_, err = reader.Get(1001)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Key 1001 should not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = reader.Get(0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Key 0 should not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("All tests passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSTableHeaderSerialization(t *testing.T) {
|
||||||
|
// 创建 Header
|
||||||
|
header := &SSTableHeader{
|
||||||
|
Magic: SSTableMagicNumber,
|
||||||
|
Version: SSTableVersion,
|
||||||
|
Compression: SSTableCompressionSnappy,
|
||||||
|
IndexOffset: 256,
|
||||||
|
IndexSize: 1024,
|
||||||
|
RootOffset: 512,
|
||||||
|
DataOffset: 2048,
|
||||||
|
DataSize: 10240,
|
||||||
|
RowCount: 100,
|
||||||
|
MinKey: 1,
|
||||||
|
MaxKey: 100,
|
||||||
|
MinTime: 1000000,
|
||||||
|
MaxTime: 1000100,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化
|
||||||
|
data := header.Marshal()
|
||||||
|
if len(data) != SSTableHeaderSize {
|
||||||
|
t.Errorf("Expected size %d, got %d", SSTableHeaderSize, len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反序列化
|
||||||
|
header2 := UnmarshalSSTableHeader(data)
|
||||||
|
if header2 == nil {
|
||||||
|
t.Fatal("Unmarshal failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if header2.Magic != header.Magic {
|
||||||
|
t.Error("Magic mismatch")
|
||||||
|
}
|
||||||
|
if header2.Version != header.Version {
|
||||||
|
t.Error("Version mismatch")
|
||||||
|
}
|
||||||
|
if header2.Compression != header.Compression {
|
||||||
|
t.Error("Compression mismatch")
|
||||||
|
}
|
||||||
|
if header2.RowCount != header.RowCount {
|
||||||
|
t.Error("RowCount mismatch")
|
||||||
|
}
|
||||||
|
if header2.MinKey != header.MinKey {
|
||||||
|
t.Error("MinKey mismatch")
|
||||||
|
}
|
||||||
|
if header2.MaxKey != header.MaxKey {
|
||||||
|
t.Error("MaxKey mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if !header2.Validate() {
|
||||||
|
t.Error("Header validation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Header serialization test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSSTableGet(b *testing.B) {
|
||||||
|
// 创建测试文件
|
||||||
|
file, _ := os.Create("bench.sst")
|
||||||
|
defer os.Remove("bench.sst")
|
||||||
|
|
||||||
|
writer := NewSSTableWriter(file, nil)
|
||||||
|
for i := int64(1); i <= 10000; i++ {
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: i,
|
||||||
|
Time: 1000000 + i,
|
||||||
|
Data: map[string]any{
|
||||||
|
"value": i,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writer.Add(row)
|
||||||
|
}
|
||||||
|
writer.Finish()
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// 打开读取器
|
||||||
|
reader, _ := NewSSTableReader("bench.sst")
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// 性能测试
|
||||||
|
|
||||||
|
for i := 0; b.Loop(); i++ {
|
||||||
|
key := int64(i%10000 + 1)
|
||||||
|
reader.Get(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSTableBinaryEncoding(t *testing.T) {
|
||||||
|
// 创建 Schema
|
||||||
|
schema := &Schema{
|
||||||
|
Name: "users",
|
||||||
|
Fields: []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
{Name: "email", Type: FieldTypeString},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: 12345,
|
||||||
|
Time: 1234567890,
|
||||||
|
Data: map[string]any{
|
||||||
|
"name": "test_user",
|
||||||
|
"age": int64(25),
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码
|
||||||
|
encoded, err := encodeSSTableRowBinary(row, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Encoded size: %d bytes", len(encoded))
|
||||||
|
|
||||||
|
// 解码
|
||||||
|
decoded, err := decodeSSTableRowBinary(encoded, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if decoded.Seq != row.Seq {
|
||||||
|
t.Errorf("Seq mismatch: expected %d, got %d", row.Seq, decoded.Seq)
|
||||||
|
}
|
||||||
|
if decoded.Time != row.Time {
|
||||||
|
t.Errorf("Time mismatch: expected %d, got %d", row.Time, decoded.Time)
|
||||||
|
}
|
||||||
|
if decoded.Data["name"] != row.Data["name"] {
|
||||||
|
t.Errorf("Name mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Binary encoding test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSTableEncodingComparison(t *testing.T) {
|
||||||
|
// 创建 Schema
|
||||||
|
schema := &Schema{
|
||||||
|
Name: "users",
|
||||||
|
Fields: []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
{Name: "email", Type: FieldTypeString},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: 12345,
|
||||||
|
Time: 1234567890,
|
||||||
|
Data: map[string]any{
|
||||||
|
"name": "test_user",
|
||||||
|
"age": int64(25),
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二进制编码
|
||||||
|
binaryEncoded, _ := encodeSSTableRowBinary(row, schema)
|
||||||
|
|
||||||
|
// JSON 编码 (旧方式)
|
||||||
|
jsonData := map[string]any{
|
||||||
|
"_seq": row.Seq,
|
||||||
|
"_time": row.Time,
|
||||||
|
"data": row.Data,
|
||||||
|
}
|
||||||
|
jsonEncoded, _ := json.Marshal(jsonData)
|
||||||
|
|
||||||
|
t.Logf("Binary size: %d bytes", len(binaryEncoded))
|
||||||
|
t.Logf("JSON size: %d bytes", len(jsonEncoded))
|
||||||
|
t.Logf("Space saved: %.1f%%", float64(len(jsonEncoded)-len(binaryEncoded))/float64(len(jsonEncoded))*100)
|
||||||
|
|
||||||
|
if len(binaryEncoded) >= len(jsonEncoded) {
|
||||||
|
t.Error("Binary encoding should be smaller than JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSSTableBinaryEncoding(b *testing.B) {
|
||||||
|
// 创建 Schema
|
||||||
|
schema := &Schema{
|
||||||
|
Name: "users",
|
||||||
|
Fields: []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
{Name: "email", Type: FieldTypeString},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: 12345,
|
||||||
|
Time: 1234567890,
|
||||||
|
Data: map[string]any{
|
||||||
|
"name": "test_user",
|
||||||
|
"age": int64(25),
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
encodeSSTableRowBinary(row, schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSSTableJSONEncoding(b *testing.B) {
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: 12345,
|
||||||
|
Time: 1234567890,
|
||||||
|
Data: map[string]any{
|
||||||
|
"name": "test_user",
|
||||||
|
"age": 25,
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"_seq": row.Seq,
|
||||||
|
"_time": row.Time,
|
||||||
|
"data": row.Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
json.Marshal(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSTablePerFieldCompression(t *testing.T) {
|
||||||
|
// 创建 Schema
|
||||||
|
schema := &Schema{
|
||||||
|
Name: "users",
|
||||||
|
Fields: []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "age", Type: FieldTypeInt64, Indexed: false},
|
||||||
|
{Name: "email", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "score", Type: FieldTypeFloat, Indexed: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: 12345,
|
||||||
|
Time: 1234567890,
|
||||||
|
Data: map[string]any{
|
||||||
|
"name": "test_user",
|
||||||
|
"age": int64(25),
|
||||||
|
"email": "test@example.com",
|
||||||
|
"score": 95.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Schema 编码(按字段压缩)
|
||||||
|
encoded, err := encodeSSTableRowBinary(row, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Per-field compressed size: %d bytes", len(encoded))
|
||||||
|
|
||||||
|
// 完整解码
|
||||||
|
decoded, err := decodeSSTableRowBinary(encoded, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证完整数据
|
||||||
|
if decoded.Seq != row.Seq {
|
||||||
|
t.Errorf("Seq mismatch: expected %d, got %d", row.Seq, decoded.Seq)
|
||||||
|
}
|
||||||
|
if decoded.Time != row.Time {
|
||||||
|
t.Errorf("Time mismatch: expected %d, got %d", row.Time, decoded.Time)
|
||||||
|
}
|
||||||
|
if decoded.Data["name"] != row.Data["name"] {
|
||||||
|
t.Errorf("Name mismatch")
|
||||||
|
}
|
||||||
|
if decoded.Data["age"] != row.Data["age"] {
|
||||||
|
t.Errorf("Age mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部分解码 - 只读取 name 和 age 字段
|
||||||
|
partialDecoded, err := decodeSSTableRowBinaryPartial(encoded, schema, []string{"name", "age"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证只包含请求的字段
|
||||||
|
if len(partialDecoded.Data) != 2 {
|
||||||
|
t.Errorf("Expected 2 fields, got %d", len(partialDecoded.Data))
|
||||||
|
}
|
||||||
|
if _, ok := partialDecoded.Data["name"]; !ok {
|
||||||
|
t.Error("Missing field: name")
|
||||||
|
}
|
||||||
|
if _, ok := partialDecoded.Data["age"]; !ok {
|
||||||
|
t.Error("Missing field: age")
|
||||||
|
}
|
||||||
|
if _, ok := partialDecoded.Data["email"]; ok {
|
||||||
|
t.Error("Should not have field: email")
|
||||||
|
}
|
||||||
|
if _, ok := partialDecoded.Data["score"]; ok {
|
||||||
|
t.Error("Should not have field: score")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证字段值正确
|
||||||
|
if partialDecoded.Data["name"] != "test_user" {
|
||||||
|
t.Errorf("Name mismatch: got %v", partialDecoded.Data["name"])
|
||||||
|
}
|
||||||
|
if partialDecoded.Data["age"] != int64(25) {
|
||||||
|
t.Errorf("Age mismatch: got %v", partialDecoded.Data["age"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Per-field compression test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSTablePartialReadingPerformance(t *testing.T) {
|
||||||
|
// 创建包含多个字段的 Schema
|
||||||
|
schema := &Schema{
|
||||||
|
Name: "events",
|
||||||
|
Fields: []Field{
|
||||||
|
{Name: "field1", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field2", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field3", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field4", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field5", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field6", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field7", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field8", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field9", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "field10", Type: FieldTypeString, Indexed: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建包含大量数据<E695B0><E68DAE>行
|
||||||
|
largeData := make(map[string]any)
|
||||||
|
for i := 1; i <= 10; i++ {
|
||||||
|
// 每个字段包含较大的字符串数据
|
||||||
|
largeData[fmt.Sprintf("field%d", i)] = fmt.Sprintf("This is a large data field %d with lots of content to compress", i) +
|
||||||
|
" Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &SSTableRow{
|
||||||
|
Seq: 12345,
|
||||||
|
Time: 1234567890,
|
||||||
|
Data: largeData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码
|
||||||
|
encoded, err := encodeSSTableRowBinary(row, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Total encoded size with 10 fields: %d bytes", len(encoded))
|
||||||
|
|
||||||
|
// 完整解码
|
||||||
|
fullDecoded, _ := decodeSSTableRowBinary(encoded, schema)
|
||||||
|
t.Logf("Full decode: %d fields", len(fullDecoded.Data))
|
||||||
|
|
||||||
|
// 部分解码 - 只读取 1 个字段
|
||||||
|
partialDecoded, _ := decodeSSTableRowBinaryPartial(encoded, schema, []string{"field1"})
|
||||||
|
t.Logf("Partial decode (1 field): %d fields", len(partialDecoded.Data))
|
||||||
|
|
||||||
|
// 验证部分读取确实只返回请求的字段
|
||||||
|
if len(partialDecoded.Data) != 1 {
|
||||||
|
t.Errorf("Expected 1 field, got %d", len(partialDecoded.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Partial reading performance test passed!")
|
||||||
|
}
|
||||||
6
table.go
6
table.go
@@ -4,8 +4,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb/sst"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Table 表
|
// Table 表
|
||||||
@@ -95,7 +93,7 @@ func (t *Table) Insert(data map[string]any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get 查询数据
|
// Get 查询数据
|
||||||
func (t *Table) Get(seq int64) (*sst.Row, error) {
|
func (t *Table) Get(seq int64) (*SSTableRow, error) {
|
||||||
return t.engine.Get(seq)
|
return t.engine.Get(seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ func (t *Table) ListIndexes() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stats 获取统计信息
|
// Stats 获取统计信息
|
||||||
func (t *Table) Stats() *Stats {
|
func (t *Table) Stats() *TableStats {
|
||||||
return t.engine.Stats()
|
return t.engine.Stats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
614
version.go
Normal file
614
version.go
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
package srdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadata SST 文件元数据
|
||||||
|
type FileMetadata struct {
|
||||||
|
FileNumber int64 // 文件编号
|
||||||
|
Level int // 所在层级 (0-6)
|
||||||
|
FileSize int64 // 文件大小
|
||||||
|
MinKey int64 // 最小 key
|
||||||
|
MaxKey int64 // 最大 key
|
||||||
|
RowCount int64 // 行数
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
NumLevels = 7 // L0-L6
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version 数据库的一个版本快照
|
||||||
|
type Version struct {
|
||||||
|
// 分层存储 SST 文件 (L0-L6)
|
||||||
|
Levels [NumLevels][]*FileMetadata
|
||||||
|
|
||||||
|
// 下一个文件编号
|
||||||
|
NextFileNumber int64
|
||||||
|
|
||||||
|
// 最后序列号
|
||||||
|
LastSequence int64
|
||||||
|
|
||||||
|
// 版本号
|
||||||
|
VersionNumber int64
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVersion 创建新版本
|
||||||
|
func NewVersion() *Version {
|
||||||
|
v := &Version{
|
||||||
|
NextFileNumber: 1,
|
||||||
|
LastSequence: 0,
|
||||||
|
VersionNumber: 0,
|
||||||
|
}
|
||||||
|
// 初始化每一层
|
||||||
|
for i := range NumLevels {
|
||||||
|
v.Levels[i] = make([]*FileMetadata, 0)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone 克隆版本
|
||||||
|
func (v *Version) Clone() *Version {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
|
||||||
|
newVersion := &Version{
|
||||||
|
NextFileNumber: v.NextFileNumber,
|
||||||
|
LastSequence: v.LastSequence,
|
||||||
|
VersionNumber: v.VersionNumber + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 克隆每一层
|
||||||
|
for level := range NumLevels {
|
||||||
|
newVersion.Levels[level] = make([]*FileMetadata, len(v.Levels[level]))
|
||||||
|
copy(newVersion.Levels[level], v.Levels[level])
|
||||||
|
}
|
||||||
|
|
||||||
|
return newVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply 应用版本变更
|
||||||
|
func (v *Version) Apply(edit *VersionEdit) {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
// 删除文件(按层级删除)
|
||||||
|
if len(edit.DeletedFiles) > 0 {
|
||||||
|
deleteSet := make(map[int64]bool)
|
||||||
|
for _, fileNum := range edit.DeletedFiles {
|
||||||
|
deleteSet[fileNum] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历每一层,删除文件
|
||||||
|
for level := range NumLevels {
|
||||||
|
newFiles := make([]*FileMetadata, 0)
|
||||||
|
for _, file := range v.Levels[level] {
|
||||||
|
if !deleteSet[file.FileNumber] {
|
||||||
|
newFiles = append(newFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Levels[level] = newFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加文件(按层级添加)
|
||||||
|
if len(edit.AddedFiles) > 0 {
|
||||||
|
for _, file := range edit.AddedFiles {
|
||||||
|
if file.Level >= 0 && file.Level < NumLevels {
|
||||||
|
v.Levels[file.Level] = append(v.Levels[file.Level], file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新下一个文件编号
|
||||||
|
if edit.NextFileNumber != nil {
|
||||||
|
v.NextFileNumber = *edit.NextFileNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后序列号
|
||||||
|
if edit.LastSequence != nil {
|
||||||
|
v.LastSequence = *edit.LastSequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevel 获取指定层级的文件
|
||||||
|
func (v *Version) GetLevel(level int) []*FileMetadata {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
|
||||||
|
if level < 0 || level >= NumLevels {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]*FileMetadata, len(v.Levels[level]))
|
||||||
|
copy(files, v.Levels[level])
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSTFiles 获取所有 SST 文件(副本,兼容旧接口)
|
||||||
|
func (v *Version) GetSSTFiles() []*FileMetadata {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
|
||||||
|
// 收集所有层级的文件
|
||||||
|
allFiles := make([]*FileMetadata, 0)
|
||||||
|
for level := range NumLevels {
|
||||||
|
allFiles = append(allFiles, v.Levels[level]...)
|
||||||
|
}
|
||||||
|
return allFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextFileNumber 获取下一个文件编号
|
||||||
|
func (v *Version) GetNextFileNumber() int64 {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
return v.NextFileNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastSequence 获取最后序列号
|
||||||
|
func (v *Version) GetLastSequence() int64 {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
return v.LastSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileCount 获取文件数量
|
||||||
|
func (v *Version) GetFileCount() int {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
for level := range NumLevels {
|
||||||
|
total += len(v.Levels[level])
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevelFileCount 获取指定层级的文件数量
|
||||||
|
func (v *Version) GetLevelFileCount(level int) int {
|
||||||
|
v.mu.RLock()
|
||||||
|
defer v.mu.RUnlock()
|
||||||
|
|
||||||
|
if level < 0 || level >= NumLevels {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(v.Levels[level])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestReader MANIFEST 读取器
|
||||||
|
type ManifestReader struct {
|
||||||
|
file io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManifestReader 创建 MANIFEST 读取器
|
||||||
|
func NewManifestReader(file io.Reader) *ManifestReader {
|
||||||
|
return &ManifestReader{
|
||||||
|
file: file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadEdit 读取版本变更
|
||||||
|
func (r *ManifestReader) ReadEdit() (*VersionEdit, error) {
|
||||||
|
// 读取 CRC32 和 Length
|
||||||
|
header := make([]byte, 8)
|
||||||
|
_, err := io.ReadFull(r.file, header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取长度
|
||||||
|
length := binary.LittleEndian.Uint32(header[4:8])
|
||||||
|
|
||||||
|
// 读取数据
|
||||||
|
data := make([]byte, 8+length)
|
||||||
|
copy(data[0:8], header)
|
||||||
|
_, err = io.ReadFull(r.file, data[8:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码
|
||||||
|
edit := NewVersionEdit()
|
||||||
|
err = edit.Decode(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return edit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestWriter MANIFEST 写入器
|
||||||
|
type ManifestWriter struct {
|
||||||
|
file io.Writer
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManifestWriter 创建 MANIFEST 写入器
|
||||||
|
func NewManifestWriter(file io.Writer) *ManifestWriter {
|
||||||
|
return &ManifestWriter{
|
||||||
|
file: file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEdit 写入版本变更
|
||||||
|
func (w *ManifestWriter) WriteEdit(edit *VersionEdit) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
// 编码
|
||||||
|
data, err := edit.Encode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入
|
||||||
|
_, err = w.file.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditType 变更类型
|
||||||
|
type EditType byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
EditTypeAddFile EditType = 1 // 添加文件
|
||||||
|
EditTypeDeleteFile EditType = 2 // 删除文件
|
||||||
|
EditTypeSetNextFile EditType = 3 // 设置下一个文件编号
|
||||||
|
EditTypeSetLastSeq EditType = 4 // 设置最后序列号
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionEdit 版本变更记录
|
||||||
|
type VersionEdit struct {
|
||||||
|
// 添加的文件
|
||||||
|
AddedFiles []*FileMetadata
|
||||||
|
|
||||||
|
// 删除的文件(文件编号列表)
|
||||||
|
DeletedFiles []int64
|
||||||
|
|
||||||
|
// 下一个文件编号
|
||||||
|
NextFileNumber *int64
|
||||||
|
|
||||||
|
// 最后序列号
|
||||||
|
LastSequence *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVersionEdit 创建版本变更
|
||||||
|
func NewVersionEdit() *VersionEdit {
|
||||||
|
return &VersionEdit{
|
||||||
|
AddedFiles: make([]*FileMetadata, 0),
|
||||||
|
DeletedFiles: make([]int64, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFile 添加文件
|
||||||
|
func (e *VersionEdit) AddFile(file *FileMetadata) {
|
||||||
|
e.AddedFiles = append(e.AddedFiles, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile 删除文件
|
||||||
|
func (e *VersionEdit) DeleteFile(fileNumber int64) {
|
||||||
|
e.DeletedFiles = append(e.DeletedFiles, fileNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNextFileNumber 设置下一个文件编号
|
||||||
|
func (e *VersionEdit) SetNextFileNumber(num int64) {
|
||||||
|
e.NextFileNumber = &num
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastSequence 设置最后序列号
|
||||||
|
func (e *VersionEdit) SetLastSequence(seq int64) {
|
||||||
|
e.LastSequence = &seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode 编码为字节
|
||||||
|
func (e *VersionEdit) Encode() ([]byte, error) {
|
||||||
|
// 使用 JSON 编码(简单实现)
|
||||||
|
data, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式: CRC32(4) + Length(4) + Data
|
||||||
|
totalLen := 8 + len(data)
|
||||||
|
buf := make([]byte, totalLen)
|
||||||
|
|
||||||
|
// 计算 CRC32
|
||||||
|
crc := crc32.ChecksumIEEE(data)
|
||||||
|
binary.LittleEndian.PutUint32(buf[0:4], crc)
|
||||||
|
|
||||||
|
// 写入长度
|
||||||
|
binary.LittleEndian.PutUint32(buf[4:8], uint32(len(data)))
|
||||||
|
|
||||||
|
// 写入数据
|
||||||
|
copy(buf[8:], data)
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode 从字节解码
|
||||||
|
func (e *VersionEdit) Decode(data []byte) error {
|
||||||
|
if len(data) < 8 {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 CRC32
|
||||||
|
crc := binary.LittleEndian.Uint32(data[0:4])
|
||||||
|
|
||||||
|
// 读取长度
|
||||||
|
length := binary.LittleEndian.Uint32(data[4:8])
|
||||||
|
|
||||||
|
if len(data) < int(8+length) {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取数据
|
||||||
|
editData := data[8 : 8+length]
|
||||||
|
|
||||||
|
// 验证 CRC32
|
||||||
|
if crc32.ChecksumIEEE(editData) != crc {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 解码
|
||||||
|
return json.Unmarshal(editData, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionSet 版本集合管理器
|
||||||
|
type VersionSet struct {
|
||||||
|
// 当前版本
|
||||||
|
current *Version
|
||||||
|
|
||||||
|
// MANIFEST 文件
|
||||||
|
manifestFile *os.File
|
||||||
|
manifestWriter *ManifestWriter
|
||||||
|
manifestNumber int64
|
||||||
|
|
||||||
|
// 下一个文件编号
|
||||||
|
nextFileNumber atomic.Int64
|
||||||
|
|
||||||
|
// 最后序列号
|
||||||
|
lastSequence atomic.Int64
|
||||||
|
|
||||||
|
// 目录
|
||||||
|
dir string
|
||||||
|
|
||||||
|
// 锁
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVersionSet 创建版本集合
|
||||||
|
func NewVersionSet(dir string) (*VersionSet, error) {
|
||||||
|
vs := &VersionSet{
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
err := os.MkdirAll(dir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 CURRENT 文件
|
||||||
|
currentFile := filepath.Join(dir, "CURRENT")
|
||||||
|
data, err := os.ReadFile(currentFile)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// CURRENT 不存在,创建新的 MANIFEST
|
||||||
|
return vs, vs.createNewManifest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 MANIFEST 文件
|
||||||
|
manifestName := strings.TrimSpace(string(data))
|
||||||
|
manifestPath := filepath.Join(dir, manifestName)
|
||||||
|
|
||||||
|
// 恢复版本信息
|
||||||
|
version, err := vs.recoverFromManifest(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
vs.current = version
|
||||||
|
vs.nextFileNumber.Store(version.NextFileNumber)
|
||||||
|
vs.lastSequence.Store(version.LastSequence)
|
||||||
|
|
||||||
|
// 解析 MANIFEST 编号
|
||||||
|
fmt.Sscanf(manifestName, "MANIFEST-%d", &vs.manifestNumber)
|
||||||
|
|
||||||
|
// 打开 MANIFEST 用于追加
|
||||||
|
file, err := os.OpenFile(manifestPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vs.manifestFile = file
|
||||||
|
vs.manifestWriter = NewManifestWriter(file)
|
||||||
|
|
||||||
|
return vs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNewManifest 创建新的 MANIFEST
|
||||||
|
func (vs *VersionSet) createNewManifest() error {
|
||||||
|
// 生成新的 MANIFEST 文件名
|
||||||
|
vs.manifestNumber = vs.nextFileNumber.Add(1)
|
||||||
|
manifestName := fmt.Sprintf("MANIFEST-%06d", vs.manifestNumber)
|
||||||
|
manifestPath := filepath.Join(vs.dir, manifestName)
|
||||||
|
|
||||||
|
// 创建 MANIFEST 文件
|
||||||
|
file, err := os.Create(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vs.manifestFile = file
|
||||||
|
vs.manifestWriter = NewManifestWriter(file)
|
||||||
|
|
||||||
|
// 创建初始版本
|
||||||
|
vs.current = NewVersion()
|
||||||
|
|
||||||
|
// 写入初始版本
|
||||||
|
edit := NewVersionEdit()
|
||||||
|
nextFile := vs.manifestNumber
|
||||||
|
edit.SetNextFileNumber(nextFile)
|
||||||
|
lastSeq := int64(0)
|
||||||
|
edit.SetLastSequence(lastSeq)
|
||||||
|
|
||||||
|
err = vs.manifestWriter.WriteEdit(edit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步到磁盘
|
||||||
|
err = vs.manifestFile.Sync()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 CURRENT 文件
|
||||||
|
return vs.updateCurrent(manifestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverFromManifest 从 MANIFEST 恢复版本
|
||||||
|
func (vs *VersionSet) recoverFromManifest(manifestPath string) (*Version, error) {
|
||||||
|
// 打开 MANIFEST 文件
|
||||||
|
file, err := os.Open(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := NewManifestReader(file)
|
||||||
|
|
||||||
|
// 创建初始版本
|
||||||
|
version := NewVersion()
|
||||||
|
|
||||||
|
// 读取所有 VersionEdit
|
||||||
|
for {
|
||||||
|
edit, err := reader.ReadEdit()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用变更
|
||||||
|
version.Apply(edit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCurrent 更新 CURRENT 文件
|
||||||
|
func (vs *VersionSet) updateCurrent(manifestName string) error {
|
||||||
|
currentPath := filepath.Join(vs.dir, "CURRENT")
|
||||||
|
tmpPath := currentPath + ".tmp"
|
||||||
|
|
||||||
|
// 1. 写入临时文件
|
||||||
|
err := os.WriteFile(tmpPath, []byte(manifestName+"\n"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 原子性重命名
|
||||||
|
err = os.Rename(tmpPath, currentPath)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAndApply 记录并应用版本变更
|
||||||
|
func (vs *VersionSet) LogAndApply(edit *VersionEdit) error {
|
||||||
|
vs.mu.Lock()
|
||||||
|
defer vs.mu.Unlock()
|
||||||
|
|
||||||
|
// 1. 创建新版本
|
||||||
|
newVersion := vs.current.Clone()
|
||||||
|
|
||||||
|
// 2. 应用变更
|
||||||
|
newVersion.Apply(edit)
|
||||||
|
|
||||||
|
// 3. 写入 MANIFEST
|
||||||
|
err := vs.manifestWriter.WriteEdit(edit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 同步到磁盘
|
||||||
|
err = vs.manifestFile.Sync()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 更新当前版本
|
||||||
|
vs.current = newVersion
|
||||||
|
|
||||||
|
// 6. 更新原子变量
|
||||||
|
if edit.NextFileNumber != nil {
|
||||||
|
// 使用 CAS 循环确保只在新值更大时更新,避免并发回退
|
||||||
|
for {
|
||||||
|
old := vs.nextFileNumber.Load()
|
||||||
|
if *edit.NextFileNumber <= old {
|
||||||
|
break // 新值不大于当前值,不更新
|
||||||
|
}
|
||||||
|
if vs.nextFileNumber.CompareAndSwap(old, *edit.NextFileNumber) {
|
||||||
|
break // 更新成功
|
||||||
|
}
|
||||||
|
// CAS 失败,重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edit.LastSequence != nil {
|
||||||
|
vs.lastSequence.Store(*edit.LastSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrent 获取当前版本
|
||||||
|
func (vs *VersionSet) GetCurrent() *Version {
|
||||||
|
vs.mu.RLock()
|
||||||
|
defer vs.mu.RUnlock()
|
||||||
|
return vs.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextFileNumber 获取下一个文件编号
|
||||||
|
func (vs *VersionSet) GetNextFileNumber() int64 {
|
||||||
|
return vs.nextFileNumber.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateFileNumber 分配文件编号
|
||||||
|
func (vs *VersionSet) AllocateFileNumber() int64 {
|
||||||
|
return vs.nextFileNumber.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastSequence 获取最后序列号
|
||||||
|
func (vs *VersionSet) GetLastSequence() int64 {
|
||||||
|
return vs.lastSequence.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastSequence 设置最后序列号
|
||||||
|
func (vs *VersionSet) SetLastSequence(seq int64) {
|
||||||
|
vs.lastSequence.Store(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭 VersionSet
|
||||||
|
func (vs *VersionSet) Close() error {
|
||||||
|
vs.mu.Lock()
|
||||||
|
defer vs.mu.Unlock()
|
||||||
|
|
||||||
|
if vs.manifestFile != nil {
|
||||||
|
return vs.manifestFile.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package srdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
408
wal.go
Normal file
408
wal.go
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
package srdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Entry 类型
|
||||||
|
WALEntryTypePut = 1
|
||||||
|
WALEntryTypeDelete = 2 // 预留,暂不支持
|
||||||
|
|
||||||
|
// Entry Header 大小
|
||||||
|
WALEntryHeaderSize = 17 // CRC32(4) + Length(4) + Type(1) + Seq(8)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WALEntry WAL 条目
|
||||||
|
type WALEntry struct {
|
||||||
|
Type byte // 操作类型
|
||||||
|
Seq int64 // _seq
|
||||||
|
Data []byte // 数据
|
||||||
|
CRC32 uint32 // 校验和
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAL Write-Ahead Log
|
||||||
|
type WAL struct {
|
||||||
|
file *os.File
|
||||||
|
offset int64
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenWAL 打开 WAL 文件
|
||||||
|
func OpenWAL(path string) (*WAL, error) {
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前文件大小
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WAL{
|
||||||
|
file: file,
|
||||||
|
offset: stat.Size(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append 追加一条记录
|
||||||
|
func (w *WAL) Append(entry *WALEntry) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
// 序列化 Entry
|
||||||
|
data := w.marshalEntry(entry)
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
_, err := w.file.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.offset += int64(len(data))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync 同步到磁盘
|
||||||
|
func (w *WAL) Sync() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
return w.file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭 WAL
|
||||||
|
func (w *WAL) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
return w.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate 清空 WAL
|
||||||
|
func (w *WAL) Truncate() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
err := w.file.Truncate(0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.file.Seek(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.offset = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalEntry 序列化 Entry
|
||||||
|
func (w *WAL) marshalEntry(entry *WALEntry) []byte {
|
||||||
|
dataLen := len(entry.Data)
|
||||||
|
totalLen := WALEntryHeaderSize + dataLen
|
||||||
|
|
||||||
|
buf := make([]byte, totalLen)
|
||||||
|
|
||||||
|
// 计算 CRC32 (不包括 CRC32 字段本身)
|
||||||
|
crcData := buf[4:totalLen]
|
||||||
|
binary.LittleEndian.PutUint32(crcData[0:4], uint32(dataLen))
|
||||||
|
crcData[4] = entry.Type
|
||||||
|
binary.LittleEndian.PutUint64(crcData[5:13], uint64(entry.Seq))
|
||||||
|
copy(crcData[13:], entry.Data)
|
||||||
|
|
||||||
|
crc := crc32.ChecksumIEEE(crcData)
|
||||||
|
|
||||||
|
// 写入 CRC32
|
||||||
|
binary.LittleEndian.PutUint32(buf[0:4], crc)
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// WALReader WAL 读取器
|
||||||
|
type WALReader struct {
|
||||||
|
file *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWALReader 创建 WAL 读取器
|
||||||
|
func NewWALReader(path string) (*WALReader, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WALReader{
|
||||||
|
file: file,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read 读取所有 Entry
|
||||||
|
func (r *WALReader) Read() ([]*WALEntry, error) {
|
||||||
|
var entries []*WALEntry
|
||||||
|
|
||||||
|
for {
|
||||||
|
entry, err := r.readEntry()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭读取器
|
||||||
|
func (r *WALReader) Close() error {
|
||||||
|
return r.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEntry 读取一条 Entry
|
||||||
|
func (r *WALReader) readEntry() (*WALEntry, error) {
|
||||||
|
// 读取 Header
|
||||||
|
header := make([]byte, WALEntryHeaderSize)
|
||||||
|
_, err := io.ReadFull(r.file, header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 Header
|
||||||
|
crc := binary.LittleEndian.Uint32(header[0:4])
|
||||||
|
dataLen := binary.LittleEndian.Uint32(header[4:8])
|
||||||
|
entryType := header[8]
|
||||||
|
seq := int64(binary.LittleEndian.Uint64(header[9:17]))
|
||||||
|
|
||||||
|
// 读取 Data
|
||||||
|
data := make([]byte, dataLen)
|
||||||
|
_, err = io.ReadFull(r.file, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 CRC32
|
||||||
|
crcData := make([]byte, WALEntryHeaderSize-4+int(dataLen))
|
||||||
|
copy(crcData[0:WALEntryHeaderSize-4], header[4:])
|
||||||
|
copy(crcData[WALEntryHeaderSize-4:], data)
|
||||||
|
|
||||||
|
if crc32.ChecksumIEEE(crcData) != crc {
|
||||||
|
return nil, io.ErrUnexpectedEOF // CRC 校验失败
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WALEntry{
|
||||||
|
Type: entryType,
|
||||||
|
Seq: seq,
|
||||||
|
Data: data,
|
||||||
|
CRC32: crc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WALManager WAL 管理器,管理多个 WAL 文件
|
||||||
|
type WALManager struct {
|
||||||
|
dir string
|
||||||
|
currentWAL *WAL
|
||||||
|
currentNumber int64
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWALManager 创建 WAL 管理器
|
||||||
|
func NewWALManager(dir string) (*WALManager, error) {
|
||||||
|
// 确保目录存在
|
||||||
|
err := os.MkdirAll(dir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取当前 WAL 编号
|
||||||
|
number, err := readWALCurrentNumber(dir)
|
||||||
|
if err != nil {
|
||||||
|
// 如果读取失败,从 1 开始
|
||||||
|
number = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开当前 WAL
|
||||||
|
walPath := filepath.Join(dir, fmt.Sprintf("%06d.wal", number))
|
||||||
|
wal, err := OpenWAL(walPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前编号
|
||||||
|
err = saveWALCurrentNumber(dir, number)
|
||||||
|
if err != nil {
|
||||||
|
wal.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WALManager{
|
||||||
|
dir: dir,
|
||||||
|
currentWAL: wal,
|
||||||
|
currentNumber: number,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append 追加记录到当前 WAL
|
||||||
|
func (m *WALManager) Append(entry *WALEntry) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.currentWAL.Append(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync 同步当前 WAL 到磁盘
|
||||||
|
func (m *WALManager) Sync() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.currentWAL.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate 切换到新的 WAL 文件
|
||||||
|
func (m *WALManager) Rotate() (int64, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// 记录旧的 WAL 编号
|
||||||
|
oldNumber := m.currentNumber
|
||||||
|
|
||||||
|
// 关闭当前 WAL
|
||||||
|
err := m.currentWAL.Close()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新 WAL
|
||||||
|
m.currentNumber++
|
||||||
|
walPath := filepath.Join(m.dir, fmt.Sprintf("%06d.wal", m.currentNumber))
|
||||||
|
wal, err := OpenWAL(walPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.currentWAL = wal
|
||||||
|
|
||||||
|
// 更新 CURRENT 文件
|
||||||
|
err = saveWALCurrentNumber(m.dir, m.currentNumber)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldNumber, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除指定的 WAL 文件
|
||||||
|
func (m *WALManager) Delete(number int64) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
walPath := filepath.Join(m.dir, fmt.Sprintf("%06d.wal", number))
|
||||||
|
return os.Remove(walPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentNumber 获取当前 WAL 编号
|
||||||
|
func (m *WALManager) GetCurrentNumber() int64 {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.currentNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverAll 恢复所有 WAL 文件
|
||||||
|
func (m *WALManager) RecoverAll() ([]*WALEntry, error) {
|
||||||
|
// 查找所有 WAL 文件
|
||||||
|
pattern := filepath.Join(m.dir, "*.wal")
|
||||||
|
files, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按文件名排序(确保按时间顺序)
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
var allEntries []*WALEntry
|
||||||
|
|
||||||
|
// 依次读取每个 WAL
|
||||||
|
for _, file := range files {
|
||||||
|
reader, err := NewWALReader(file)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := reader.Read()
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = append(allEntries, entries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEntries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWALFiles 列出所有 WAL 文件
|
||||||
|
func (m *WALManager) ListWALFiles() ([]string, error) {
|
||||||
|
pattern := filepath.Join(m.dir, "*.wal")
|
||||||
|
files, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭 WAL 管理器
|
||||||
|
func (m *WALManager) Close() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.currentWAL != nil {
|
||||||
|
return m.currentWAL.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWALCurrentNumber 读取当前 WAL 编号
|
||||||
|
func readWALCurrentNumber(dir string) (int64, error) {
|
||||||
|
currentPath := filepath.Join(dir, "CURRENT")
|
||||||
|
data, err := os.ReadFile(currentPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
number, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveWALCurrentNumber 保存当前 WAL 编号
|
||||||
|
func saveWALCurrentNumber(dir string, number int64) error {
|
||||||
|
currentPath := filepath.Join(dir, "CURRENT")
|
||||||
|
data := fmt.Appendf(nil, "%d\n", number)
|
||||||
|
return os.WriteFile(currentPath, data, 0644)
|
||||||
|
}
|
||||||
206
wal/manager.go
206
wal/manager.go
@@ -1,206 +0,0 @@
|
|||||||
package wal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager WAL 管理器,管理多个 WAL 文件
|
|
||||||
type Manager struct {
|
|
||||||
dir string
|
|
||||||
currentWAL *WAL
|
|
||||||
currentNumber int64
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager 创建 WAL 管理器
|
|
||||||
func NewManager(dir string) (*Manager, error) {
|
|
||||||
// 确保目录存在
|
|
||||||
err := os.MkdirAll(dir, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取当前 WAL 编号
|
|
||||||
number, err := readCurrentNumber(dir)
|
|
||||||
if err != nil {
|
|
||||||
// 如果读取失败,从 1 开始
|
|
||||||
number = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开当前 WAL
|
|
||||||
walPath := filepath.Join(dir, fmt.Sprintf("%06d.wal", number))
|
|
||||||
wal, err := Open(walPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存当前编号
|
|
||||||
err = saveCurrentNumber(dir, number)
|
|
||||||
if err != nil {
|
|
||||||
wal.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Manager{
|
|
||||||
dir: dir,
|
|
||||||
currentWAL: wal,
|
|
||||||
currentNumber: number,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append 追加记录到当前 WAL
|
|
||||||
func (m *Manager) Append(entry *Entry) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
return m.currentWAL.Append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync 同步当前 WAL 到磁盘
|
|
||||||
func (m *Manager) Sync() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
return m.currentWAL.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate 切换到新的 WAL 文件
|
|
||||||
func (m *Manager) Rotate() (int64, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
// 记录旧的 WAL 编号
|
|
||||||
oldNumber := m.currentNumber
|
|
||||||
|
|
||||||
// 关闭当前 WAL
|
|
||||||
err := m.currentWAL.Close()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新 WAL
|
|
||||||
m.currentNumber++
|
|
||||||
walPath := filepath.Join(m.dir, fmt.Sprintf("%06d.wal", m.currentNumber))
|
|
||||||
wal, err := Open(walPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.currentWAL = wal
|
|
||||||
|
|
||||||
// 更新 CURRENT 文件
|
|
||||||
err = saveCurrentNumber(m.dir, m.currentNumber)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return oldNumber, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 删除指定的 WAL 文件
|
|
||||||
func (m *Manager) Delete(number int64) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
walPath := filepath.Join(m.dir, fmt.Sprintf("%06d.wal", number))
|
|
||||||
return os.Remove(walPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentNumber 获取当前 WAL 编号
|
|
||||||
func (m *Manager) GetCurrentNumber() int64 {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
return m.currentNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecoverAll 恢复所有 WAL 文件
|
|
||||||
func (m *Manager) RecoverAll() ([]*Entry, error) {
|
|
||||||
// 查找所有 WAL 文件
|
|
||||||
pattern := filepath.Join(m.dir, "*.wal")
|
|
||||||
files, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按文件名排序(确保按时间顺序)
|
|
||||||
sort.Strings(files)
|
|
||||||
|
|
||||||
var allEntries []*Entry
|
|
||||||
|
|
||||||
// 依次读取每个 WAL
|
|
||||||
for _, file := range files {
|
|
||||||
reader, err := NewReader(file)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := reader.Read()
|
|
||||||
reader.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
allEntries = append(allEntries, entries...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return allEntries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListWALFiles 列出所有 WAL 文件
|
|
||||||
func (m *Manager) ListWALFiles() ([]string, error) {
|
|
||||||
pattern := filepath.Join(m.dir, "*.wal")
|
|
||||||
files, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(files)
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭 WAL 管理器
|
|
||||||
func (m *Manager) Close() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.currentWAL != nil {
|
|
||||||
return m.currentWAL.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCurrentNumber 读取当前 WAL 编号
|
|
||||||
func readCurrentNumber(dir string) (int64, error) {
|
|
||||||
currentPath := filepath.Join(dir, "CURRENT")
|
|
||||||
data, err := os.ReadFile(currentPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
number, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return number, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveCurrentNumber 保存当前 WAL 编号
|
|
||||||
func saveCurrentNumber(dir string, number int64) error {
|
|
||||||
currentPath := filepath.Join(dir, "CURRENT")
|
|
||||||
data := []byte(fmt.Sprintf("%d\n", number))
|
|
||||||
return os.WriteFile(currentPath, data, 0644)
|
|
||||||
}
|
|
||||||
208
wal/wal.go
208
wal/wal.go
@@ -1,208 +0,0 @@
|
|||||||
package wal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"hash/crc32"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Entry 类型
|
|
||||||
EntryTypePut = 1
|
|
||||||
EntryTypeDelete = 2 // 预留,暂不支持
|
|
||||||
|
|
||||||
// Entry Header 大小
|
|
||||||
EntryHeaderSize = 17 // CRC32(4) + Length(4) + Type(1) + Seq(8)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Entry WAL 条目
|
|
||||||
type Entry struct {
|
|
||||||
Type byte // 操作类型
|
|
||||||
Seq int64 // _seq
|
|
||||||
Data []byte // 数据
|
|
||||||
CRC32 uint32 // 校验和
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAL Write-Ahead Log
|
|
||||||
type WAL struct {
|
|
||||||
file *os.File
|
|
||||||
offset int64
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open 打开 WAL 文件
|
|
||||||
func Open(path string) (*WAL, error) {
|
|
||||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前文件大小
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &WAL{
|
|
||||||
file: file,
|
|
||||||
offset: stat.Size(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append 追加一条记录
|
|
||||||
func (w *WAL) Append(entry *Entry) error {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
// 序列化 Entry
|
|
||||||
data := w.marshalEntry(entry)
|
|
||||||
|
|
||||||
// 写入文件
|
|
||||||
_, err := w.file.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
w.offset += int64(len(data))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync 同步到磁盘
|
|
||||||
func (w *WAL) Sync() error {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
return w.file.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭 WAL
|
|
||||||
func (w *WAL) Close() error {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
return w.file.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate 清空 WAL
|
|
||||||
func (w *WAL) Truncate() error {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
err := w.file.Truncate(0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.file.Seek(0, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
w.offset = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// marshalEntry 序列化 Entry
|
|
||||||
func (w *WAL) marshalEntry(entry *Entry) []byte {
|
|
||||||
dataLen := len(entry.Data)
|
|
||||||
totalLen := EntryHeaderSize + dataLen
|
|
||||||
|
|
||||||
buf := make([]byte, totalLen)
|
|
||||||
|
|
||||||
// 计算 CRC32 (不包括 CRC32 字段本身)
|
|
||||||
crcData := buf[4:totalLen]
|
|
||||||
binary.LittleEndian.PutUint32(crcData[0:4], uint32(dataLen))
|
|
||||||
crcData[4] = entry.Type
|
|
||||||
binary.LittleEndian.PutUint64(crcData[5:13], uint64(entry.Seq))
|
|
||||||
copy(crcData[13:], entry.Data)
|
|
||||||
|
|
||||||
crc := crc32.ChecksumIEEE(crcData)
|
|
||||||
|
|
||||||
// 写入 CRC32
|
|
||||||
binary.LittleEndian.PutUint32(buf[0:4], crc)
|
|
||||||
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reader WAL 读取器
|
|
||||||
type Reader struct {
|
|
||||||
file *os.File
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReader 创建 WAL 读取器
|
|
||||||
func NewReader(path string) (*Reader, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Reader{
|
|
||||||
file: file,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read 读取所有 Entry
|
|
||||||
func (r *Reader) Read() ([]*Entry, error) {
|
|
||||||
var entries []*Entry
|
|
||||||
|
|
||||||
for {
|
|
||||||
entry, err := r.readEntry()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭读取器
|
|
||||||
func (r *Reader) Close() error {
|
|
||||||
return r.file.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// readEntry 读取一条 Entry
|
|
||||||
func (r *Reader) readEntry() (*Entry, error) {
|
|
||||||
// 读取 Header
|
|
||||||
header := make([]byte, EntryHeaderSize)
|
|
||||||
_, err := io.ReadFull(r.file, header)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 Header
|
|
||||||
crc := binary.LittleEndian.Uint32(header[0:4])
|
|
||||||
dataLen := binary.LittleEndian.Uint32(header[4:8])
|
|
||||||
entryType := header[8]
|
|
||||||
seq := int64(binary.LittleEndian.Uint64(header[9:17]))
|
|
||||||
|
|
||||||
// 读取 Data
|
|
||||||
data := make([]byte, dataLen)
|
|
||||||
_, err = io.ReadFull(r.file, data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 CRC32
|
|
||||||
crcData := make([]byte, EntryHeaderSize-4+int(dataLen))
|
|
||||||
copy(crcData[0:EntryHeaderSize-4], header[4:])
|
|
||||||
copy(crcData[EntryHeaderSize-4:], data)
|
|
||||||
|
|
||||||
if crc32.ChecksumIEEE(crcData) != crc {
|
|
||||||
return nil, io.ErrUnexpectedEOF // CRC 校验失败
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Entry{
|
|
||||||
Type: entryType,
|
|
||||||
Seq: seq,
|
|
||||||
Data: data,
|
|
||||||
CRC32: crc,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package wal
|
package srdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func TestWAL(t *testing.T) {
|
func TestWAL(t *testing.T) {
|
||||||
// 1. 创建 WAL
|
// 1. 创建 WAL
|
||||||
wal, err := Open("test.wal")
|
wal, err := OpenWAL("test.wal")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,8 @@ func TestWAL(t *testing.T) {
|
|||||||
|
|
||||||
// 2. 写入数据
|
// 2. 写入数据
|
||||||
for i := int64(1); i <= 100; i++ {
|
for i := int64(1); i <= 100; i++ {
|
||||||
entry := &Entry{
|
entry := &WALEntry{
|
||||||
Type: EntryTypePut,
|
Type: WALEntryTypePut,
|
||||||
Seq: i,
|
Seq: i,
|
||||||
Data: []byte("value_" + string(rune(i))),
|
Data: []byte("value_" + string(rune(i))),
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestWAL(t *testing.T) {
|
|||||||
t.Log("Written 100 entries")
|
t.Log("Written 100 entries")
|
||||||
|
|
||||||
// 4. 读取数据
|
// 4. 读取数据
|
||||||
reader, err := NewReader("test.wal")
|
reader, err := NewWALReader("test.wal")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,8 @@ func TestWAL(t *testing.T) {
|
|||||||
if entry.Seq != expectedSeq {
|
if entry.Seq != expectedSeq {
|
||||||
t.Errorf("Entry %d: expected Seq=%d, got %d", i, expectedSeq, entry.Seq)
|
t.Errorf("Entry %d: expected Seq=%d, got %d", i, expectedSeq, entry.Seq)
|
||||||
}
|
}
|
||||||
if entry.Type != EntryTypePut {
|
if entry.Type != WALEntryTypePut {
|
||||||
t.Errorf("Entry %d: expected Type=%d, got %d", i, EntryTypePut, entry.Type)
|
t.Errorf("Entry %d: expected Type=%d, got %d", i, WALEntryTypePut, entry.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ func TestWAL(t *testing.T) {
|
|||||||
|
|
||||||
func TestWALTruncate(t *testing.T) {
|
func TestWALTruncate(t *testing.T) {
|
||||||
// 创建 WAL
|
// 创建 WAL
|
||||||
wal, err := Open("test_truncate.wal")
|
wal, err := OpenWAL("test_truncate.wal")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,8 @@ func TestWALTruncate(t *testing.T) {
|
|||||||
|
|
||||||
// 写入数据
|
// 写入数据
|
||||||
for i := int64(1); i <= 10; i++ {
|
for i := int64(1); i <= 10; i++ {
|
||||||
entry := &Entry{
|
entry := &WALEntry{
|
||||||
Type: EntryTypePut,
|
Type: WALEntryTypePut,
|
||||||
Seq: i,
|
Seq: i,
|
||||||
Data: []byte("value"),
|
Data: []byte("value"),
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func TestWALTruncate(t *testing.T) {
|
|||||||
wal.Close()
|
wal.Close()
|
||||||
|
|
||||||
// 验证文件为空
|
// 验证文件为空
|
||||||
reader, err := NewReader("test_truncate.wal")
|
reader, err := NewWALReader("test_truncate.wal")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -112,18 +112,17 @@ func TestWALTruncate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkWALAppend(b *testing.B) {
|
func BenchmarkWALAppend(b *testing.B) {
|
||||||
wal, _ := Open("bench.wal")
|
wal, _ := OpenWAL("bench.wal")
|
||||||
defer os.Remove("bench.wal")
|
defer os.Remove("bench.wal")
|
||||||
defer wal.Close()
|
defer wal.Close()
|
||||||
|
|
||||||
entry := &Entry{
|
entry := &WALEntry{
|
||||||
Type: EntryTypePut,
|
Type: WALEntryTypePut,
|
||||||
Seq: 1,
|
Seq: 1,
|
||||||
Data: make([]byte, 100),
|
Data: make([]byte, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ResetTimer()
|
for i := 0; b.Loop(); i++ {
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
entry.Seq = int64(i)
|
entry.Seq = int64(i)
|
||||||
wal.Append(entry)
|
wal.Append(entry)
|
||||||
}
|
}
|
||||||
552
webui/htmx.go
552
webui/htmx.go
@@ -1,552 +0,0 @@
|
|||||||
package webui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"html"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HTML 渲染辅助函数
|
|
||||||
|
|
||||||
// renderTablesHTML 渲染表列表 HTML
|
|
||||||
func renderTablesHTML(tables []TableListItem) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
for _, table := range tables {
|
|
||||||
buf.WriteString(`<div class="table-item" data-table="`)
|
|
||||||
buf.WriteString(html.EscapeString(table.Name))
|
|
||||||
buf.WriteString(`">`)
|
|
||||||
buf.WriteString(`<div class="table-header" onclick="selectTable('`)
|
|
||||||
buf.WriteString(html.EscapeString(table.Name))
|
|
||||||
buf.WriteString(`')">`)
|
|
||||||
|
|
||||||
// 左侧:展开图标和表名
|
|
||||||
buf.WriteString(`<div class="table-header-left">`)
|
|
||||||
buf.WriteString(`<span class="expand-icon" onclick="event.stopPropagation(); toggleExpand('`)
|
|
||||||
buf.WriteString(html.EscapeString(table.Name))
|
|
||||||
buf.WriteString(`')">▶</span>`)
|
|
||||||
buf.WriteString(`<span class="table-name">`)
|
|
||||||
buf.WriteString(html.EscapeString(table.Name))
|
|
||||||
buf.WriteString(`</span></div>`)
|
|
||||||
|
|
||||||
// 右侧:字段数量
|
|
||||||
buf.WriteString(`<span class="table-count">`)
|
|
||||||
buf.WriteString(formatCount(int64(len(table.Fields))))
|
|
||||||
buf.WriteString(` fields</span>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// Schema 字段列表(默认隐藏)
|
|
||||||
if len(table.Fields) > 0 {
|
|
||||||
buf.WriteString(`<div class="schema-fields">`)
|
|
||||||
for _, field := range table.Fields {
|
|
||||||
buf.WriteString(`<div class="field-item">`)
|
|
||||||
buf.WriteString(`<span class="field-name">`)
|
|
||||||
buf.WriteString(html.EscapeString(field.Name))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
buf.WriteString(`<span class="field-type">`)
|
|
||||||
buf.WriteString(html.EscapeString(field.Type))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
if field.Indexed {
|
|
||||||
buf.WriteString(`<span class="field-indexed">●indexed</span>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDataViewHTML 渲染数据视图 HTML
|
|
||||||
func renderDataViewHTML(tableName string, schema SchemaInfo, tableData TableDataResponse) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
buf.WriteString(`<h2>`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`</h2>`)
|
|
||||||
|
|
||||||
// 视图切换标签
|
|
||||||
buf.WriteString(`<div class="view-tabs">`)
|
|
||||||
buf.WriteString(`<button class="view-tab active" onclick="switchView('`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`', 'data')">Data</button>`)
|
|
||||||
buf.WriteString(`<button class="view-tab" onclick="switchView('`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`', 'manifest')">Manifest / LSM-Tree</button>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// Schema 部分
|
|
||||||
if len(schema.Fields) > 0 {
|
|
||||||
buf.WriteString(`<div class="schema-section">`)
|
|
||||||
buf.WriteString(`<h3>Schema <span style="font-size: 12px; font-weight: 400; color: var(--text-secondary);">(点击字段卡片选择要显示的列)</span></h3>`)
|
|
||||||
buf.WriteString(`<div class="schema-grid">`)
|
|
||||||
for _, field := range schema.Fields {
|
|
||||||
buf.WriteString(`<div class="schema-field-card selected" data-column="`)
|
|
||||||
buf.WriteString(html.EscapeString(field.Name))
|
|
||||||
buf.WriteString(`" onclick="toggleColumn('`)
|
|
||||||
buf.WriteString(html.EscapeString(field.Name))
|
|
||||||
buf.WriteString(`')">`)
|
|
||||||
buf.WriteString(`<div class="field-item">`)
|
|
||||||
buf.WriteString(`<span class="field-name">`)
|
|
||||||
buf.WriteString(html.EscapeString(field.Name))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
buf.WriteString(`<span class="field-type">`)
|
|
||||||
buf.WriteString(html.EscapeString(field.Type))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
if field.Indexed {
|
|
||||||
buf.WriteString(`<span class="field-indexed">●indexed</span>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
buf.WriteString(`<div class="field-comment">`)
|
|
||||||
if field.Comment != "" {
|
|
||||||
buf.WriteString(html.EscapeString(field.Comment))
|
|
||||||
}
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数据表格
|
|
||||||
buf.WriteString(`<h3>Data (`)
|
|
||||||
buf.WriteString(formatCount(tableData.TotalRows))
|
|
||||||
buf.WriteString(` rows)</h3>`)
|
|
||||||
|
|
||||||
if len(tableData.Data) == 0 {
|
|
||||||
buf.WriteString(`<div class="empty"><p>No data available</p></div>`)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取列并排序:_seq 第1列,_time 倒数第2列
|
|
||||||
columns := []string{}
|
|
||||||
otherColumns := []string{}
|
|
||||||
hasSeq := false
|
|
||||||
hasTime := false
|
|
||||||
|
|
||||||
if len(tableData.Data) > 0 {
|
|
||||||
for key := range tableData.Data[0] {
|
|
||||||
if !strings.HasSuffix(key, "_truncated") {
|
|
||||||
if key == "_seq" {
|
|
||||||
hasSeq = true
|
|
||||||
} else if key == "_time" {
|
|
||||||
hasTime = true
|
|
||||||
} else {
|
|
||||||
otherColumns = append(otherColumns, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按顺序组装:_seq, 其他列, _time
|
|
||||||
if hasSeq {
|
|
||||||
columns = append(columns, "_seq")
|
|
||||||
}
|
|
||||||
columns = append(columns, otherColumns...)
|
|
||||||
if hasTime {
|
|
||||||
columns = append(columns, "_time")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格
|
|
||||||
buf.WriteString(`<div class="table-wrapper">`)
|
|
||||||
buf.WriteString(`<table class="data-table">`)
|
|
||||||
buf.WriteString(`<thead><tr>`)
|
|
||||||
for _, col := range columns {
|
|
||||||
buf.WriteString(`<th data-column="`)
|
|
||||||
buf.WriteString(html.EscapeString(col))
|
|
||||||
buf.WriteString(`" title="`)
|
|
||||||
buf.WriteString(html.EscapeString(col))
|
|
||||||
buf.WriteString(`">`)
|
|
||||||
buf.WriteString(html.EscapeString(col))
|
|
||||||
buf.WriteString(`</th>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`<th style="width: 80px;">Actions</th>`)
|
|
||||||
buf.WriteString(`</tr></thead>`)
|
|
||||||
|
|
||||||
buf.WriteString(`<tbody>`)
|
|
||||||
for _, row := range tableData.Data {
|
|
||||||
buf.WriteString(`<tr>`)
|
|
||||||
for _, col := range columns {
|
|
||||||
value := row[col]
|
|
||||||
buf.WriteString(`<td data-column="`)
|
|
||||||
buf.WriteString(html.EscapeString(col))
|
|
||||||
buf.WriteString(`" onclick="showCellContent('`)
|
|
||||||
buf.WriteString(escapeJSString(fmt.Sprintf("%v", value)))
|
|
||||||
buf.WriteString(`')" title="Click to view full content">`)
|
|
||||||
buf.WriteString(html.EscapeString(fmt.Sprintf("%v", value)))
|
|
||||||
|
|
||||||
// 检查是否被截断
|
|
||||||
if truncated, ok := row[col+"_truncated"]; ok && truncated == true {
|
|
||||||
buf.WriteString(`<span class="truncated-icon" title="This field is truncated">✂️</span>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString(`</td>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions 列
|
|
||||||
buf.WriteString(`<td style="text-align: center;">`)
|
|
||||||
buf.WriteString(`<button class="row-detail-btn" onclick="showRowDetail('`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`', `)
|
|
||||||
buf.WriteString(fmt.Sprintf("%v", row["_seq"]))
|
|
||||||
buf.WriteString(`)" title="View full row data">Detail</button>`)
|
|
||||||
buf.WriteString(`</td>`)
|
|
||||||
|
|
||||||
buf.WriteString(`</tr>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`</tbody>`)
|
|
||||||
buf.WriteString(`</table>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
buf.WriteString(renderPagination(tableData))
|
|
||||||
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderManifestViewHTML 渲染 Manifest 视图 HTML
|
|
||||||
func renderManifestViewHTML(tableName string, manifest ManifestResponse) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
buf.WriteString(`<h2>`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`</h2>`)
|
|
||||||
|
|
||||||
// 视图切换标签
|
|
||||||
buf.WriteString(`<div class="view-tabs">`)
|
|
||||||
buf.WriteString(`<button class="view-tab" onclick="switchView('`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`', 'data')">Data</button>`)
|
|
||||||
buf.WriteString(`<button class="view-tab active" onclick="switchView('`)
|
|
||||||
buf.WriteString(html.EscapeString(tableName))
|
|
||||||
buf.WriteString(`', 'manifest')">Manifest / LSM-Tree</button>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// 标题和控制按钮
|
|
||||||
buf.WriteString(`<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">`)
|
|
||||||
buf.WriteString(`<h3>LSM-Tree Structure</h3>`)
|
|
||||||
buf.WriteString(`<div class="control-buttons">`)
|
|
||||||
buf.WriteString(`<button>📖 Expand All</button>`)
|
|
||||||
buf.WriteString(`<button>📕 Collapse All</button>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// 统计卡片
|
|
||||||
totalLevels := len(manifest.Levels)
|
|
||||||
totalFiles := 0
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, level := range manifest.Levels {
|
|
||||||
totalFiles += level.FileCount
|
|
||||||
totalSize += level.TotalSize
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString(`<div class="manifest-stats">`)
|
|
||||||
|
|
||||||
// Active Levels
|
|
||||||
buf.WriteString(`<div class="stat-card">`)
|
|
||||||
buf.WriteString(`<div class="stat-label">Active Levels</div>`)
|
|
||||||
buf.WriteString(`<div class="stat-value">`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", totalLevels))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// Total Files
|
|
||||||
buf.WriteString(`<div class="stat-card">`)
|
|
||||||
buf.WriteString(`<div class="stat-label">Total Files</div>`)
|
|
||||||
buf.WriteString(`<div class="stat-value">`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", totalFiles))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// Total Size
|
|
||||||
buf.WriteString(`<div class="stat-card">`)
|
|
||||||
buf.WriteString(`<div class="stat-label">Total Size</div>`)
|
|
||||||
buf.WriteString(`<div class="stat-value">`)
|
|
||||||
buf.WriteString(formatBytes(totalSize))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// Next File Number
|
|
||||||
buf.WriteString(`<div class="stat-card">`)
|
|
||||||
buf.WriteString(`<div class="stat-label">Next File Number</div>`)
|
|
||||||
buf.WriteString(`<div class="stat-value">`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", manifest.NextFileNumber))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// Last Sequence
|
|
||||||
buf.WriteString(`<div class="stat-card">`)
|
|
||||||
buf.WriteString(`<div class="stat-label">Last Sequence</div>`)
|
|
||||||
buf.WriteString(`<div class="stat-value">`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", manifest.LastSequence))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// Total Compactions
|
|
||||||
buf.WriteString(`<div class="stat-card">`)
|
|
||||||
buf.WriteString(`<div class="stat-label">Total Compactions</div>`)
|
|
||||||
buf.WriteString(`<div class="stat-value">`)
|
|
||||||
totalCompactions := 0
|
|
||||||
if manifest.CompactionStats != nil {
|
|
||||||
if tc, ok := manifest.CompactionStats["total_compactions"]; ok {
|
|
||||||
if tcInt, ok := tc.(float64); ok {
|
|
||||||
totalCompactions = int(tcInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", totalCompactions))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// 渲染所有层级(L0-L6)
|
|
||||||
for i := 0; i <= 6; i++ {
|
|
||||||
var level *LevelInfo
|
|
||||||
for j := range manifest.Levels {
|
|
||||||
if manifest.Levels[j].Level == i {
|
|
||||||
level = &manifest.Levels[j]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if level == nil {
|
|
||||||
// 创建空层级
|
|
||||||
level = &LevelInfo{
|
|
||||||
Level: i,
|
|
||||||
FileCount: 0,
|
|
||||||
TotalSize: 0,
|
|
||||||
Score: 0,
|
|
||||||
Files: []FileInfo{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString(renderLevelCard(*level))
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderLevelCard 渲染层级卡片
|
|
||||||
func renderLevelCard(level LevelInfo) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
scoreClass := "normal"
|
|
||||||
if level.Score >= 1.0 {
|
|
||||||
scoreClass = "critical"
|
|
||||||
} else if level.Score >= 0.8 {
|
|
||||||
scoreClass = "warning"
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.WriteString(`<div class="level-card" data-level="`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", level.Level))
|
|
||||||
buf.WriteString(`">`)
|
|
||||||
buf.WriteString(`<div class="level-header" onclick="toggleLevel(`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", level.Level))
|
|
||||||
buf.WriteString(`)">`)
|
|
||||||
|
|
||||||
// 左侧:展开图标和标题
|
|
||||||
buf.WriteString(`<div style="display: flex; align-items: center; gap: 10px;">`)
|
|
||||||
buf.WriteString(`<span class="expand-icon">▶</span>`)
|
|
||||||
buf.WriteString(`<div class="level-title">Level `)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", level.Level))
|
|
||||||
buf.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// 右侧:统计信息
|
|
||||||
buf.WriteString(`<div class="level-stats">`)
|
|
||||||
buf.WriteString(`<span>`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", level.FileCount))
|
|
||||||
buf.WriteString(` files</span>`)
|
|
||||||
buf.WriteString(`<span>`)
|
|
||||||
buf.WriteString(formatBytes(level.TotalSize))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
buf.WriteString(`<span class="score-badge `)
|
|
||||||
buf.WriteString(scoreClass)
|
|
||||||
buf.WriteString(`">Score: `)
|
|
||||||
buf.WriteString(fmt.Sprintf("%.2f", level.Score))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
// 文件列表(默认隐藏)
|
|
||||||
buf.WriteString(`<div class="file-list">`)
|
|
||||||
if len(level.Files) == 0 {
|
|
||||||
buf.WriteString(`<div class="empty-files">No files in this level</div>`)
|
|
||||||
} else {
|
|
||||||
for _, file := range level.Files {
|
|
||||||
buf.WriteString(`<div class="file-card">`)
|
|
||||||
buf.WriteString(`<div class="file-header">`)
|
|
||||||
buf.WriteString(`<span>File #`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", file.FileNumber))
|
|
||||||
buf.WriteString(`</span>`)
|
|
||||||
buf.WriteString(`<b>`)
|
|
||||||
buf.WriteString(formatBytes(file.FileSize))
|
|
||||||
buf.WriteString(`</b>`)
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
buf.WriteString(`<div class="file-detail">`)
|
|
||||||
buf.WriteString(`<span>Key Range:</span>`)
|
|
||||||
buf.WriteString(`<span>`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d - %d", file.MinKey, file.MaxKey))
|
|
||||||
buf.WriteString(`</span></div>`)
|
|
||||||
|
|
||||||
buf.WriteString(`<div class="file-detail">`)
|
|
||||||
buf.WriteString(`<span>Rows:</span>`)
|
|
||||||
buf.WriteString(`<span>`)
|
|
||||||
buf.WriteString(formatCount(file.RowCount))
|
|
||||||
buf.WriteString(`</span></div>`)
|
|
||||||
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPagination 渲染分页 HTML
|
|
||||||
func renderPagination(data TableDataResponse) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
buf.WriteString(`<div class="pagination">`)
|
|
||||||
|
|
||||||
// 页大小选择器
|
|
||||||
buf.WriteString(`<select onchange="changePageSize(this.value)">`)
|
|
||||||
for _, size := range []int{10, 20, 50, 100} {
|
|
||||||
buf.WriteString(`<option value="`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", size))
|
|
||||||
buf.WriteString(`"`)
|
|
||||||
if int64(size) == data.PageSize {
|
|
||||||
buf.WriteString(` selected`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`>`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", size))
|
|
||||||
buf.WriteString(` / page</option>`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`</select>`)
|
|
||||||
|
|
||||||
// 上一页按钮
|
|
||||||
buf.WriteString(`<button onclick="changePage(-1)"`)
|
|
||||||
if data.Page <= 1 {
|
|
||||||
buf.WriteString(` disabled`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`>Previous</button>`)
|
|
||||||
|
|
||||||
// 页码信息
|
|
||||||
buf.WriteString(`<span>Page `)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", data.Page))
|
|
||||||
buf.WriteString(` of `)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", data.TotalPages))
|
|
||||||
buf.WriteString(` (`)
|
|
||||||
buf.WriteString(formatCount(data.TotalRows))
|
|
||||||
buf.WriteString(` rows)</span>`)
|
|
||||||
|
|
||||||
// 跳转输入框
|
|
||||||
buf.WriteString(`<input type="number" min="1" max="`)
|
|
||||||
buf.WriteString(fmt.Sprintf("%d", data.TotalPages))
|
|
||||||
buf.WriteString(`" placeholder="Jump to" onkeydown="if(event.key==='Enter') jumpToPage(this.value)">`)
|
|
||||||
|
|
||||||
// Go 按钮
|
|
||||||
buf.WriteString(`<button onclick="jumpToPage(this.previousElementSibling.value)">Go</button>`)
|
|
||||||
|
|
||||||
// 下一页按钮
|
|
||||||
buf.WriteString(`<button onclick="changePage(1)"`)
|
|
||||||
if data.Page >= data.TotalPages {
|
|
||||||
buf.WriteString(` disabled`)
|
|
||||||
}
|
|
||||||
buf.WriteString(`>Next</button>`)
|
|
||||||
|
|
||||||
buf.WriteString(`</div>`)
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBytes 格式化字节数
|
|
||||||
func formatBytes(bytes int64) string {
|
|
||||||
if bytes == 0 {
|
|
||||||
return "0 B"
|
|
||||||
}
|
|
||||||
const k = 1024
|
|
||||||
sizes := []string{"B", "KB", "MB", "GB", "TB"}
|
|
||||||
i := 0
|
|
||||||
size := float64(bytes)
|
|
||||||
for size >= k && i < len(sizes)-1 {
|
|
||||||
size /= k
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.2f %s", size, sizes[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatCount 格式化数量(K/M)
|
|
||||||
func formatCount(count int64) string {
|
|
||||||
if count >= 1000000 {
|
|
||||||
return fmt.Sprintf("%.1fM", float64(count)/1000000)
|
|
||||||
}
|
|
||||||
if count >= 1000 {
|
|
||||||
return fmt.Sprintf("%.1fK", float64(count)/1000)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// escapeJSString 转义 JavaScript 字符串
|
|
||||||
func escapeJSString(s string) string {
|
|
||||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
||||||
s = strings.ReplaceAll(s, `'`, `\'`)
|
|
||||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
|
||||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
|
||||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
|
||||||
s = strings.ReplaceAll(s, "\t", `\t`)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数据结构定义
|
|
||||||
type TableListItem struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
Fields []FieldInfo `json:"fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FieldInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Indexed bool `json:"indexed"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SchemaInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Fields []FieldInfo `json:"fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TableDataResponse struct {
|
|
||||||
Data []map[string]any `json:"data"`
|
|
||||||
Page int64 `json:"page"`
|
|
||||||
PageSize int64 `json:"pageSize"`
|
|
||||||
TotalRows int64 `json:"totalRows"`
|
|
||||||
TotalPages int64 `json:"totalPages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ManifestResponse struct {
|
|
||||||
Levels []LevelInfo `json:"levels"`
|
|
||||||
NextFileNumber int64 `json:"next_file_number"`
|
|
||||||
LastSequence int64 `json:"last_sequence"`
|
|
||||||
CompactionStats map[string]any `json:"compaction_stats"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LevelInfo struct {
|
|
||||||
Level int `json:"level"`
|
|
||||||
FileCount int `json:"file_count"`
|
|
||||||
TotalSize int64 `json:"total_size"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
Files []FileInfo `json:"files"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileInfo struct {
|
|
||||||
FileNumber int64 `json:"file_number"`
|
|
||||||
Level int `json:"level"`
|
|
||||||
FileSize int64 `json:"file_size"`
|
|
||||||
MinKey int64 `json:"min_key"`
|
|
||||||
MaxKey int64 `json:"max_key"`
|
|
||||||
RowCount int64 `json:"row_count"`
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* SRDB WebUI - Modern Design */
|
/* SRDB WebUI - Modern Design with Lit */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* 主色调 - 优雅的紫蓝色 */
|
/* 主色调 - 优雅的紫蓝色 */
|
||||||
@@ -53,6 +53,11 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* 主题过渡动画 */
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
"Inter",
|
"Inter",
|
||||||
@@ -67,837 +72,9 @@ body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
/* 布局 */
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
|
||||||
/* 侧边栏 */
|
|
||||||
.sidebar {
|
|
||||||
width: 280px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: 16px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar h1 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
background: linear-gradient(135deg, var(--primary-light), var(--primary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主内容区 */
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background: var(--bg-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--text-primary) 0%,
|
|
||||||
var(--primary-light) 100%
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main h3::before {
|
|
||||||
content: "";
|
|
||||||
width: 3px;
|
|
||||||
height: 18px;
|
|
||||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表列表卡片 */
|
|
||||||
.table-item {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header {
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
/*transform: translateX(2px);*/
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header.selected,
|
|
||||||
.table-item.selected .table-header {
|
|
||||||
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-icon {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: var(--transition);
|
|
||||||
user-select: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-icon.expanded {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-count {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 3px 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-item.selected .table-count {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Schema 字段列表 */
|
|
||||||
.schema-fields {
|
|
||||||
display: none;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-left: 2px solid var(--primary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
gap: 6px;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-width: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-type {
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: "SF Mono", Monaco, monospace;
|
|
||||||
color: var(--primary-light);
|
|
||||||
background: rgba(99, 102, 241, 0.15);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-indexed {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--success);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-comment {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 视图切换标签 */
|
|
||||||
.view-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 4px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab {
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: var(--transition);
|
|
||||||
position: relative;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab.active {
|
|
||||||
color: white;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--primary) 0%,
|
|
||||||
var(--primary-dark) 100%
|
|
||||||
);
|
|
||||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Schema 展示 */
|
|
||||||
/*.schema-section {
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(99, 102, 241, 0.05) 0%,
|
|
||||||
rgba(99, 102, 241, 0.02) 100%
|
|
||||||
);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
.schema-section h3 {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
margin-top: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schema-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schema-field-card {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 12px;
|
|
||||||
transition: var(--transition);
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schema-field-card.selected {
|
|
||||||
opacity: 1;
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(99, 102, 241, 0.1) 0%,
|
|
||||||
rgba(99, 102, 241, 0.05) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*.schema-field-card::after {
|
|
||||||
content: "✓";
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
right: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--primary);
|
|
||||||
opacity: 0;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schema-field-card.selected::after {
|
|
||||||
opacity: 1;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
.schema-field-card:hover {
|
|
||||||
border-color: var(--primary-light);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 数据表格 */
|
|
||||||
.table-wrapper {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
padding: 10px 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
max-width: 400px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr {
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分页 */
|
|
||||||
.pagination {
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button,
|
|
||||||
.pagination select,
|
|
||||||
.pagination input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button:hover:not(:disabled) {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination input[type="number"] {
|
|
||||||
width: 80px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination select {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Manifest / LSM-Tree */
|
|
||||||
.level-card {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-card:hover {
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--text-primary),
|
|
||||||
var(--text-secondary)
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-badge.normal {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-badge.warning {
|
|
||||||
background: rgba(245, 158, 11, 0.15);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-badge.critical {
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list {
|
|
||||||
display: none;
|
|
||||||
margin-top: 12px;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
padding-top: 8px;
|
|
||||||
/*border-top: 1px solid var(--border-color);*/
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-card {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-detail {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
/*padding: 4px 0;*/
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
z-index: 1000;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
animation: fadeIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
max-width: 90%;
|
|
||||||
max-height: 85%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
animation: slideUp 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(20px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
transition: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 16px;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: "SF Mono", Monaco, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮 */
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-detail-btn {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-detail-btn:hover {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 空状态和加载 */
|
|
||||||
.loading,
|
|
||||||
.empty,
|
|
||||||
.error {
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Manifest stats */
|
|
||||||
.manifest-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 14px;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, var(--primary-light), var(--primary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 100%;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
max-height: 40vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schema-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manifest-stats {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列选择器 */
|
|
||||||
.column-selector {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.columns-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-checkbox:hover {
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-checkbox.selected {
|
|
||||||
background: var(--primary-bg);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-checkbox input {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具按钮 */
|
|
||||||
.control-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons button {
|
|
||||||
padding: 6px 14px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons button:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单元格大小指示器 */
|
|
||||||
.cell-size {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-style: italic;
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 截断指示器 */
|
|
||||||
.truncated-icon {
|
|
||||||
color: var(--warning);
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -11,59 +11,15 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||||
<script src="https://npm.onmicrosoft.cn/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<!-- 应用容器 -->
|
||||||
<!-- 左侧表列表 -->
|
<srdb-app></srdb-app>
|
||||||
<div class="sidebar" id="sidebar">
|
|
||||||
<h1>Tables</h1>
|
|
||||||
<div
|
|
||||||
id="table-list"
|
|
||||||
hx-get="/api/tables-html"
|
|
||||||
hx-trigger="load"
|
|
||||||
>
|
|
||||||
Loading tables...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧主内容区 -->
|
|
||||||
<div class="main" id="main-content">
|
|
||||||
<div class="empty">
|
|
||||||
<h2>Select a table to view data</h2>
|
|
||||||
<p>Choose a table from the sidebar to get started</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
<div id="modal" class="modal" style="display: none">
|
<srdb-modal-dialog></srdb-modal-dialog>
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 id="modal-title">Content</h3>
|
|
||||||
<button class="modal-close" onclick="closeModal()">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<pre id="modal-body-content"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<!-- 加载 Lit 组件 -->
|
||||||
|
<script type="module" src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,199 +1,110 @@
|
|||||||
// SRDB WebUI - htmx 版本
|
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';
|
||||||
|
|
||||||
// 全局状态
|
class App {
|
||||||
window.srdbState = {
|
constructor() {
|
||||||
selectedTable: null,
|
// 等待 srdb-app 组件渲染完成
|
||||||
currentPage: 1,
|
this.appContainer = document.querySelector('srdb-app');
|
||||||
pageSize: 20,
|
this.modal = document.querySelector('srdb-modal-dialog');
|
||||||
selectedColumns: [],
|
|
||||||
expandedTables: new Set(),
|
|
||||||
expandedLevels: new Set([0, 1]),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 选择表格
|
// 等待组件初始化
|
||||||
function selectTable(tableName) {
|
if (this.appContainer) {
|
||||||
window.srdbState.selectedTable = tableName;
|
// 使用 updateComplete 等待组件渲染完成
|
||||||
window.srdbState.currentPage = 1;
|
this.appContainer.updateComplete.then(() => {
|
||||||
|
this.tableList = this.appContainer.shadowRoot.querySelector('srdb-table-list');
|
||||||
// 高亮选中的表
|
this.tableView = this.appContainer.shadowRoot.querySelector('srdb-table-view');
|
||||||
document.querySelectorAll(".table-item").forEach((el) => {
|
this.pageHeader = this.appContainer.shadowRoot.querySelector('srdb-page-header');
|
||||||
el.classList.toggle("selected", el.dataset.table === tableName);
|
this.setupEventListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 加载表数据
|
|
||||||
loadTableData(tableName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载表数据
|
|
||||||
function loadTableData(tableName) {
|
|
||||||
const mainContent = document.getElementById("main-content");
|
|
||||||
mainContent.innerHTML = '<div class="loading">Loading...</div>';
|
|
||||||
|
|
||||||
fetch(
|
|
||||||
`/api/tables-view/${tableName}?page=${window.srdbState.currentPage}&pageSize=${window.srdbState.pageSize}`,
|
|
||||||
)
|
|
||||||
.then((res) => res.text())
|
|
||||||
.then((html) => {
|
|
||||||
mainContent.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to load table data:", err);
|
|
||||||
mainContent.innerHTML =
|
|
||||||
'<div class="error">Failed to load table data</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换视图 (Data / Manifest)
|
|
||||||
function switchView(tableName, mode) {
|
|
||||||
const mainContent = document.getElementById("main-content");
|
|
||||||
mainContent.innerHTML = '<div class="loading">Loading...</div>';
|
|
||||||
|
|
||||||
const endpoint =
|
|
||||||
mode === "manifest"
|
|
||||||
? `/api/tables-view/${tableName}/manifest`
|
|
||||||
: `/api/tables-view/${tableName}?page=${window.srdbState.currentPage}&pageSize=${window.srdbState.pageSize}`;
|
|
||||||
|
|
||||||
fetch(endpoint)
|
|
||||||
.then((res) => res.text())
|
|
||||||
.then((html) => {
|
|
||||||
mainContent.innerHTML = html;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
function changePage(delta) {
|
|
||||||
window.srdbState.currentPage += delta;
|
|
||||||
if (window.srdbState.selectedTable) {
|
|
||||||
loadTableData(window.srdbState.selectedTable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function jumpToPage(page) {
|
|
||||||
window.srdbState.currentPage = parseInt(page);
|
|
||||||
if (window.srdbState.selectedTable) {
|
|
||||||
loadTableData(window.srdbState.selectedTable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePageSize(newSize) {
|
|
||||||
window.srdbState.pageSize = parseInt(newSize);
|
|
||||||
window.srdbState.currentPage = 1;
|
|
||||||
if (window.srdbState.selectedTable) {
|
|
||||||
loadTableData(window.srdbState.selectedTable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal 相关
|
|
||||||
function showModal(title, content) {
|
|
||||||
document.getElementById("modal-title").textContent = title;
|
|
||||||
document.getElementById("modal-body-content").textContent = content;
|
|
||||||
document.getElementById("modal").style.display = "flex";
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
document.getElementById("modal").style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCellContent(content) {
|
|
||||||
showModal("Cell Content", content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRowDetail(tableName, seq) {
|
|
||||||
fetch(`/api/tables/${tableName}/data/${seq}`)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const formatted = JSON.stringify(data, null, 2);
|
|
||||||
showModal(`Row Detail (Seq: ${seq})`, formatted);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to load row detail:", err);
|
|
||||||
alert("Failed to load row detail");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 折叠展开
|
|
||||||
function toggleExpand(tableName) {
|
|
||||||
const item = document.querySelector(`[data-table="${tableName}"]`);
|
|
||||||
const fieldsDiv = item.querySelector(".schema-fields");
|
|
||||||
const icon = item.querySelector(".expand-icon");
|
|
||||||
|
|
||||||
if (window.srdbState.expandedTables.has(tableName)) {
|
|
||||||
window.srdbState.expandedTables.delete(tableName);
|
|
||||||
fieldsDiv.style.display = "none";
|
|
||||||
icon.classList.remove("expanded");
|
|
||||||
} else {
|
} else {
|
||||||
window.srdbState.expandedTables.add(tableName);
|
// 如果组件还未定义,等待它被定义
|
||||||
fieldsDiv.style.display = "block";
|
customElements.whenDefined('srdb-app').then(() => {
|
||||||
icon.classList.add("expanded");
|
this.appContainer = document.querySelector('srdb-app');
|
||||||
|
this.appContainer.updateComplete.then(() => {
|
||||||
|
this.tableList = this.appContainer.shadowRoot.querySelector('srdb-table-list');
|
||||||
|
this.tableView = this.appContainer.shadowRoot.querySelector('srdb-table-view');
|
||||||
|
this.pageHeader = this.appContainer.shadowRoot.querySelector('srdb-page-header');
|
||||||
|
this.setupEventListeners();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function toggleLevel(level) {
|
|
||||||
const levelCard = document.querySelector(`[data-level="${level}"]`);
|
|
||||||
const fileList = levelCard.querySelector(".file-list");
|
|
||||||
const icon = levelCard.querySelector(".expand-icon");
|
|
||||||
|
|
||||||
if (window.srdbState.expandedLevels.has(level)) {
|
|
||||||
window.srdbState.expandedLevels.delete(level);
|
|
||||||
fileList.style.display = "none";
|
|
||||||
icon.classList.remove("expanded");
|
|
||||||
} else {
|
|
||||||
window.srdbState.expandedLevels.add(level);
|
|
||||||
fileList.style.display = "grid";
|
|
||||||
icon.classList.add("expanded");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化工具
|
setupEventListeners() {
|
||||||
function formatBytes(bytes) {
|
// Listen for table selection
|
||||||
if (bytes === 0) return "0 B";
|
document.addEventListener('table-selected', (e) => {
|
||||||
const k = 1024;
|
const tableName = e.detail.tableName;
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
this.pageHeader.tableName = tableName;
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
this.pageHeader.view = 'data';
|
||||||
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
|
this.tableView.tableName = tableName;
|
||||||
}
|
this.tableView.view = 'data';
|
||||||
|
this.tableView.page = 1;
|
||||||
function formatCount(count) {
|
|
||||||
if (count >= 1000000) return (count / 1000000).toFixed(1) + "M";
|
|
||||||
if (count >= 1000) return (count / 1000).toFixed(1) + "K";
|
|
||||||
return count.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击 modal 外部关闭
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
const modal = document.getElementById("modal");
|
|
||||||
if (e.target === modal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ESC 键关闭 modal
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 切换列显示
|
|
||||||
function toggleColumn(columnName) {
|
|
||||||
// 切换 schema-field-card 的选中状态
|
|
||||||
const card = document.querySelector(
|
|
||||||
`.schema-field-card[data-column="${columnName}"]`,
|
|
||||||
);
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
card.classList.toggle("selected");
|
|
||||||
const isSelected = card.classList.contains("selected");
|
|
||||||
|
|
||||||
// 切换表格列的显示/隐藏
|
|
||||||
const headers = document.querySelectorAll(`th[data-column="${columnName}"]`);
|
|
||||||
const cells = document.querySelectorAll(`td[data-column="${columnName}"]`);
|
|
||||||
|
|
||||||
headers.forEach((header) => {
|
|
||||||
header.style.display = isSelected ? "" : "none";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cells.forEach((cell) => {
|
// Listen for view change from page-header
|
||||||
cell.style.display = isSelected ? "" : "none";
|
document.addEventListener('view-changed', (e) => {
|
||||||
|
this.tableView.view = e.detail.view;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for refresh request from page-header
|
||||||
|
document.addEventListener('refresh-view', (e) => {
|
||||||
|
this.tableView.loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for row detail request
|
||||||
|
document.addEventListener('show-row-detail', async (e) => {
|
||||||
|
const { tableName, seq } = e.detail;
|
||||||
|
await this.showRowDetail(tableName, seq);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for cell content request
|
||||||
|
document.addEventListener('show-cell-content', (e) => {
|
||||||
|
this.showCellContent(e.detail.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
this.modal.addEventListener('click', () => {
|
||||||
|
this.modal.open = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 content = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
this.modal.title = `Row Detail - Seq: ${seq}`;
|
||||||
|
this.modal.content = content;
|
||||||
|
this.modal.open = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading row detail:', error);
|
||||||
|
this.modal.title = 'Error';
|
||||||
|
this.modal.content = `Failed to load row detail: ${error.message}`;
|
||||||
|
this.modal.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showCellContent(content) {
|
||||||
|
this.modal.title = 'Cell Content';
|
||||||
|
this.modal.content = String(content);
|
||||||
|
this.modal.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize app when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => new App());
|
||||||
|
} else {
|
||||||
|
new App();
|
||||||
}
|
}
|
||||||
|
|||||||
145
webui/static/js/components/app.js
Normal file
145
webui/static/js/components/app.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class AppContainer extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
mobileMenuOpen: { type: Boolean }
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.mobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMobileMenu() {
|
||||||
|
this.mobileMenuOpen = !this.mobileMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
background: linear-gradient(135deg, var(--primary-light), var(--primary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: var(--bg-main);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 移动端遮罩 */
|
||||||
|
.mobile-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-overlay.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
border-bottom: none;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<!-- 移动端遮罩 -->
|
||||||
|
<div class="mobile-overlay ${this.mobileMenuOpen ? 'show' : ''}" @click=${this.toggleMobileMenu}></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- 左侧表列表 -->
|
||||||
|
<div class="sidebar ${this.mobileMenuOpen ? 'open' : ''}">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1>SRDB Tables</h1>
|
||||||
|
<srdb-theme-toggle></srdb-theme-toggle>
|
||||||
|
</div>
|
||||||
|
<srdb-table-list @table-selected=${this.toggleMobileMenu}></srdb-table-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧主内容区 -->
|
||||||
|
<div class="main">
|
||||||
|
<srdb-page-header @toggle-mobile-menu=${this.toggleMobileMenu}></srdb-page-header>
|
||||||
|
<srdb-table-view></srdb-table-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-app', AppContainer);
|
||||||
106
webui/static/js/components/badge.js
Normal file
106
webui/static/js/components/badge.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class Badge extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
variant: { type: String }, // 'primary', 'success', 'warning', 'danger', 'info'
|
||||||
|
icon: { type: String },
|
||||||
|
size: { type: String } // 'sm', 'md'
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.size-md {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary variant */
|
||||||
|
.badge.variant-primary {
|
||||||
|
--badge-border-color: rgba(99, 102, 241, 0.2);
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success variant */
|
||||||
|
.badge.variant-success {
|
||||||
|
--badge-border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning variant */
|
||||||
|
.badge.variant-warning {
|
||||||
|
--badge-border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger variant */
|
||||||
|
.badge.variant-danger {
|
||||||
|
--badge-border-color: rgba(239, 68, 68, 0.2);
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info variant */
|
||||||
|
.badge.variant-info {
|
||||||
|
--badge-border-color: rgba(59, 130, 246, 0.2);
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary variant */
|
||||||
|
.badge.variant-secondary {
|
||||||
|
--badge-border-color: rgba(160, 160, 176, 0.2);
|
||||||
|
background: rgba(160, 160, 176, 0.15);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.size-md .icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.variant = 'primary';
|
||||||
|
this.icon = '';
|
||||||
|
this.size = 'sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<span class="badge variant-${this.variant} size-${this.size}">
|
||||||
|
${this.icon ? html`<span class="icon">${this.icon}</span>` : ''}
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-badge', Badge);
|
||||||
351
webui/static/js/components/data-view.js
Normal file
351
webui/static/js/components/data-view.js
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class DataView extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
tableName: { type: String },
|
||||||
|
schema: { type: Object },
|
||||||
|
tableData: { type: Object },
|
||||||
|
selectedColumns: { type: Array },
|
||||||
|
loading: { type: Boolean }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0 12px 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-field-card {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-field-card:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-field-card.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-index-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-type {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-comment {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-comment:empty::before {
|
||||||
|
content: "No comment";
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td:hover {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncated-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-detail-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-detail-btn:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.tableName = '';
|
||||||
|
this.schema = null;
|
||||||
|
this.tableData = null;
|
||||||
|
this.selectedColumns = [];
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties) {
|
||||||
|
// 当 tableName 或 schema 改变时,尝试加载保存的列选择
|
||||||
|
if ((changedProperties.has('tableName') || changedProperties.has('schema')) && this.tableName && this.schema) {
|
||||||
|
const saved = this.loadSelectedColumns();
|
||||||
|
if (saved && saved.length > 0) {
|
||||||
|
// 验证保存的列是否仍然存在于当前 schema 中
|
||||||
|
const validColumns = saved.filter(col =>
|
||||||
|
this.schema.fields.some(field => field.name === col)
|
||||||
|
);
|
||||||
|
if (validColumns.length > 0) {
|
||||||
|
this.selectedColumns = validColumns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleColumn(columnName) {
|
||||||
|
const index = this.selectedColumns.indexOf(columnName);
|
||||||
|
if (index > -1) {
|
||||||
|
this.selectedColumns = this.selectedColumns.filter(c => c !== columnName);
|
||||||
|
} else {
|
||||||
|
this.selectedColumns = [...this.selectedColumns, columnName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到 localStorage
|
||||||
|
this.saveSelectedColumns();
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('columns-changed', {
|
||||||
|
detail: { columns: this.selectedColumns },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSelectedColumns() {
|
||||||
|
if (!this.tableName) return;
|
||||||
|
const key = `srdb_columns_${this.tableName}`;
|
||||||
|
localStorage.setItem(key, JSON.stringify(this.selectedColumns));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSelectedColumns() {
|
||||||
|
if (!this.tableName) return null;
|
||||||
|
const key = `srdb_columns_${this.tableName}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
return saved ? JSON.parse(saved) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
showRowDetail(seq) {
|
||||||
|
this.dispatchEvent(new CustomEvent('show-row-detail', {
|
||||||
|
detail: { tableName: this.tableName, seq },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCount(count) {
|
||||||
|
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
|
||||||
|
if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(nanoTime) {
|
||||||
|
if (!nanoTime) return '';
|
||||||
|
// 将纳秒转换为毫秒
|
||||||
|
const date = new Date(nanoTime / 1000000);
|
||||||
|
// 格式化为 YYYY-MM-DD HH:mm:ss
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColumns() {
|
||||||
|
let columns = [];
|
||||||
|
|
||||||
|
if (this.selectedColumns.length > 0) {
|
||||||
|
columns = [...this.selectedColumns];
|
||||||
|
} else {
|
||||||
|
columns = this.schema?.fields?.map(f => f.name) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保系统字段的顺序:_seq 在开头,_time 在倒数第二
|
||||||
|
const filtered = columns.filter(c => c !== '_seq' && c !== '_time');
|
||||||
|
|
||||||
|
// _seq 放开头
|
||||||
|
const result = ['_seq', ...filtered];
|
||||||
|
|
||||||
|
// _time 放倒数第二(Actions 列之前)
|
||||||
|
result.push('_time');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.loading || !this.schema || !this.tableData) {
|
||||||
|
return html`<div class="loading">Loading data...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = this.getColumns();
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.renderSchemaSection()}
|
||||||
|
|
||||||
|
<h3>Data (${this.formatCount(this.tableData.totalRows)} rows)</h3>
|
||||||
|
|
||||||
|
${this.tableData.data.length === 0 ? html`
|
||||||
|
<div class="empty"><p>No data available</p></div>
|
||||||
|
` : html`
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${columns.map(col => html`<th>${col}</th>`)}
|
||||||
|
<th style="text-align: center;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${this.tableData.data.map(row => html`
|
||||||
|
<tr>
|
||||||
|
${columns.map(col => html`
|
||||||
|
<td>
|
||||||
|
${col === '_time' ? this.formatTime(row[col]) : row[col]}
|
||||||
|
${row[col + '_truncated'] ? html`<span class="truncated-icon">✂️</span>` : ''}
|
||||||
|
</td>
|
||||||
|
`)}
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<button
|
||||||
|
class="row-detail-btn"
|
||||||
|
@click=${() => this.showRowDetail(row._seq)}
|
||||||
|
>
|
||||||
|
Detail
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSchemaSection() {
|
||||||
|
if (!this.schema || !this.schema.fields) return '';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="schema-section">
|
||||||
|
<h3>Schema <span style="font-size: 12px; font-weight: 400; color: var(--text-secondary);">(点击字段卡片选择要显示的列)</span></h3>
|
||||||
|
<div class="schema-grid">
|
||||||
|
${this.schema.fields.map(field => html`
|
||||||
|
<div
|
||||||
|
class="schema-field-card ${this.selectedColumns.includes(field.name) ? 'selected' : ''}"
|
||||||
|
@click=${() => this.toggleColumn(field.name)}
|
||||||
|
>
|
||||||
|
<div class="field-item">
|
||||||
|
<div class="field-item-row">
|
||||||
|
<srdb-field-icon
|
||||||
|
?indexed=${field.indexed}
|
||||||
|
class="field-index-icon"
|
||||||
|
title="${field.indexed ? 'Indexed field (fast)' : 'Not indexed (slow)'}"
|
||||||
|
></srdb-field-icon>
|
||||||
|
<span class="field-name">${field.name}</span>
|
||||||
|
<srdb-badge variant="primary" class="field-type">
|
||||||
|
${field.type}
|
||||||
|
</srdb-badge>
|
||||||
|
</div>
|
||||||
|
<div class="field-comment">${field.comment || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-data-view', DataView);
|
||||||
59
webui/static/js/components/field-icon.js
Normal file
59
webui/static/js/components/field-icon.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { LitElement, html, css, svg } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||||
|
|
||||||
|
export class FieldIcon extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
indexed: { type: Boolean }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indexed {
|
||||||
|
fill: var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-indexed {
|
||||||
|
fill: var(--text-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.indexed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.indexed) {
|
||||||
|
// 闪电图标 - 已索引(快速)
|
||||||
|
return html`
|
||||||
|
<svg viewBox="0 0 24 24" class="indexed" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// 圆点图标 - 未索引
|
||||||
|
return html`
|
||||||
|
<svg viewBox="0 0 24 24" class="not-indexed" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="4" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-field-icon', FieldIcon);
|
||||||
301
webui/static/js/components/manifest-view.js
Normal file
301
webui/static/js/components/manifest-view.js
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class ManifestView extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
manifestData: { type: Object },
|
||||||
|
loading: { type: Boolean },
|
||||||
|
expandedLevels: { type: Set }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0 12px 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifest-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-files {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-files.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.file-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.manifestData = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.expandedLevels = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLevel(levelNum) {
|
||||||
|
if (this.expandedLevels.has(levelNum)) {
|
||||||
|
this.expandedLevels.delete(levelNum);
|
||||||
|
} else {
|
||||||
|
this.expandedLevels.add(levelNum);
|
||||||
|
}
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatSize(bytes) {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
|
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
getScoreVariant(score) {
|
||||||
|
if (score >= 0.8) return 'danger'; // 高分 = 需要紧急 compaction
|
||||||
|
if (score >= 0.5) return 'warning'; // 中分 = 需要关注
|
||||||
|
return 'success'; // 低分 = 健康状态
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.loading || !this.manifestData) {
|
||||||
|
return html`<div class="loading">Loading manifest...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFiles = this.manifestData.levels.reduce((sum, l) => sum + l.file_count, 0);
|
||||||
|
const totalSize = this.manifestData.levels.reduce((sum, l) => sum + l.total_size, 0);
|
||||||
|
const totalCompactions = this.manifestData.compaction_stats?.total_compactions || 0;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<h3>LSM-Tree Structure</h3>
|
||||||
|
|
||||||
|
<div class="manifest-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Active Levels</div>
|
||||||
|
<div class="stat-value">${this.manifestData.levels.filter(l => l.file_count > 0).length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Total Files</div>
|
||||||
|
<div class="stat-value">${totalFiles}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Total Size</div>
|
||||||
|
<div class="stat-value">${this.formatSize(totalSize)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Compactions</div>
|
||||||
|
<div class="stat-value">${totalCompactions}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.manifestData.levels && this.manifestData.levels.length > 0
|
||||||
|
? this.manifestData.levels.map(level => this.renderLevelCard(level))
|
||||||
|
: html`
|
||||||
|
<div class="empty">
|
||||||
|
<p>No SSTable files in this table yet.</p>
|
||||||
|
<p style="font-size: 14px; margin-top: 8px;">Insert some data to see the LSM-Tree structure.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLevelCard(level) {
|
||||||
|
if (level.file_count === 0) return '';
|
||||||
|
|
||||||
|
const isExpanded = this.expandedLevels.has(level.level);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="level-card">
|
||||||
|
<div class="level-header" @click=${() => this.toggleLevel(level.level)}>
|
||||||
|
<div class="level-header-left">
|
||||||
|
<span class="expand-icon ${isExpanded ? 'expanded' : ''}">▶</span>
|
||||||
|
<div>
|
||||||
|
<div class="level-title">Level ${level.level}</div>
|
||||||
|
<div class="level-stats">
|
||||||
|
<span>${level.file_count} files</span>
|
||||||
|
<span>${this.formatSize(level.total_size)}</span>
|
||||||
|
${level.score !== undefined ? html`
|
||||||
|
<srdb-badge variant="${this.getScoreVariant(level.score)}">
|
||||||
|
Score: ${(level.score * 100).toFixed(0)}%
|
||||||
|
</srdb-badge>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${level.files && level.files.length > 0 ? html`
|
||||||
|
<div class="level-files ${isExpanded ? 'expanded' : ''}">
|
||||||
|
<div class="file-list">
|
||||||
|
${level.files.map(file => html`
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-name">${file.file_number}.sst</div>
|
||||||
|
<div class="file-detail">
|
||||||
|
<div class="file-detail-row">
|
||||||
|
<span>Size:</span>
|
||||||
|
<span>${this.formatSize(file.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-detail-row">
|
||||||
|
<span>Rows:</span>
|
||||||
|
<span>${file.row_count || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-detail-row">
|
||||||
|
<span>Seq Range:</span>
|
||||||
|
<span>${file.min_key} - ${file.max_key}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-manifest-view', ManifestView);
|
||||||
179
webui/static/js/components/modal-dialog.js
Normal file
179
webui/static/js/components/modal-dialog.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class ModalDialog extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
open: { type: Boolean },
|
||||||
|
title: { type: String },
|
||||||
|
content: { type: String }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
min-width: 324px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-content {
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
/*background: var(--bg-elevated);*/
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.open = false;
|
||||||
|
this.title = 'Content';
|
||||||
|
this.content = '';
|
||||||
|
this._handleKeyDown = this._handleKeyDown.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
document.addEventListener('keydown', this._handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('keydown', this._handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties) {
|
||||||
|
if (changedProperties.has('open')) {
|
||||||
|
if (this.open) {
|
||||||
|
this.setAttribute('open', '');
|
||||||
|
} else {
|
||||||
|
this.removeAttribute('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleKeyDown(e) {
|
||||||
|
if (this.open && e.key === 'Escape') {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('modal-close', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="modal-content" @click=${(e) => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${this.title}</h3>
|
||||||
|
<button class="modal-close" @click=${this.close}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre>${this.content}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-modal-dialog', ModalDialog);
|
||||||
267
webui/static/js/components/page-header.js
Normal file
267
webui/static/js/components/page-header.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class PageHeader extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
tableName: { type: String },
|
||||||
|
view: { type: String }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin: 0 -12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab {
|
||||||
|
position: relative;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
/*background: var(--bg-hover);*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab.active {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab::before,
|
||||||
|
.view-tab::after {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0;
|
||||||
|
inset-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab::before {
|
||||||
|
inset-block: 8px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab::after {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--primary);
|
||||||
|
bottom: -2px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab.active::before,
|
||||||
|
.view-tab.active::after,
|
||||||
|
.view-tab:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs {
|
||||||
|
margin: 0 -16px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.tableName = '';
|
||||||
|
this.view = 'data';
|
||||||
|
}
|
||||||
|
|
||||||
|
switchView(newView) {
|
||||||
|
this.view = newView;
|
||||||
|
this.dispatchEvent(new CustomEvent('view-changed', {
|
||||||
|
detail: { view: newView },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMobileMenu() {
|
||||||
|
this.dispatchEvent(new CustomEvent('toggle-mobile-menu', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshView() {
|
||||||
|
this.dispatchEvent(new CustomEvent('refresh-view', {
|
||||||
|
detail: { view: this.view },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.tableName) {
|
||||||
|
return html`
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Select a table from the sidebar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-top">
|
||||||
|
<button class="mobile-menu-btn" @click=${this.toggleMobileMenu}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2>${this.tableName}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-tabs">
|
||||||
|
<button
|
||||||
|
class="view-tab ${this.view === 'data' ? 'active' : ''}"
|
||||||
|
@click=${() => this.switchView('data')}
|
||||||
|
>
|
||||||
|
<span>Data</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="view-tab ${this.view === 'manifest' ? 'active' : ''}"
|
||||||
|
@click=${() => this.switchView('manifest')}
|
||||||
|
>
|
||||||
|
<span>Manifest / LSM-Tree</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">
|
||||||
|
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-page-header', PageHeader);
|
||||||
284
webui/static/js/components/table-list.js
Normal file
284
webui/static/js/components/table-list.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class TableList extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
tables: { type: Array },
|
||||||
|
selectedTable: { type: String },
|
||||||
|
expandedTables: { type: Set }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item.selected .table-header {
|
||||||
|
background: var(--primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item.has-expanded .table-header {
|
||||||
|
border-bottom-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.table-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schema 字段列表 */
|
||||||
|
.schema-fields {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-fields.expanded {
|
||||||
|
display: block;
|
||||||
|
border-top-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 共享的垂直线 */
|
||||||
|
.schema-fields.expanded::before {
|
||||||
|
z-index: 2;
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 24px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px 8px 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: var(--transition);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 每个字段的水平线 */
|
||||||
|
.field-item::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item:last-child {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-index-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-index-icon.indexed {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-index-icon.not-indexed {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.field-type {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.tables = [];
|
||||||
|
this.selectedTable = '';
|
||||||
|
this.expandedTables = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.loadTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTables() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tables');
|
||||||
|
if (!response.ok) throw new Error('Failed to load tables');
|
||||||
|
this.tables = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tables:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpand(tableName, event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (this.expandedTables.has(tableName)) {
|
||||||
|
this.expandedTables.delete(tableName);
|
||||||
|
} else {
|
||||||
|
this.expandedTables.add(tableName);
|
||||||
|
}
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTable(tableName) {
|
||||||
|
this.selectedTable = tableName;
|
||||||
|
this.dispatchEvent(new CustomEvent('table-selected', {
|
||||||
|
detail: { tableName },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.tables.length === 0) {
|
||||||
|
return html`<div class="loading">Loading tables...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.tables.map(table => html`
|
||||||
|
<div class="table-item ${this.expandedTables.has(table.name) ? 'has-expanded' : ''} ${this.selectedTable === table.name ? 'selected' : ''}">
|
||||||
|
<div
|
||||||
|
class="table-header"
|
||||||
|
@click=${() => this.selectTable(table.name)}
|
||||||
|
>
|
||||||
|
<div class="table-header-left">
|
||||||
|
<span
|
||||||
|
class="expand-icon ${this.expandedTables.has(table.name) ? 'expanded' : ''}"
|
||||||
|
@click=${(e) => this.toggleExpand(table.name, e)}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<span class="table-name">${table.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="table-count">${table.fields.length} fields</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="schema-fields ${this.expandedTables.has(table.name) ? 'expanded' : ''}">
|
||||||
|
${table.fields.map(field => html`
|
||||||
|
<div class="field-item">
|
||||||
|
<srdb-field-icon
|
||||||
|
?indexed=${field.indexed}
|
||||||
|
class="field-index-icon"
|
||||||
|
title="${field.indexed ? 'Indexed field (fast)' : 'Not indexed (slow)'}"
|
||||||
|
></srdb-field-icon>
|
||||||
|
<span class="field-name">${field.name}</span>
|
||||||
|
<srdb-badge variant="primary" class="field-type">
|
||||||
|
${field.type}
|
||||||
|
</srdb-badge>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-table-list', TableList);
|
||||||
364
webui/static/js/components/table-view.js
Normal file
364
webui/static/js/components/table-view.js
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class TableView extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
tableName: { type: String },
|
||||||
|
view: { type: String }, // 'data' or 'manifest'
|
||||||
|
schema: { type: Object },
|
||||||
|
tableData: { type: Object },
|
||||||
|
manifestData: { type: Object },
|
||||||
|
selectedColumns: { type: Array },
|
||||||
|
page: { type: Number },
|
||||||
|
pageSize: { type: Number },
|
||||||
|
loading: { type: Boolean }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
cssVariables,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 280px;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pagination {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination select,
|
||||||
|
.pagination input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination input {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.tableName = '';
|
||||||
|
this.view = 'data';
|
||||||
|
this.schema = null;
|
||||||
|
this.tableData = null;
|
||||||
|
this.manifestData = null;
|
||||||
|
this.selectedColumns = [];
|
||||||
|
this.page = 1;
|
||||||
|
this.pageSize = 20;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties) {
|
||||||
|
if (changedProperties.has('tableName') && this.tableName) {
|
||||||
|
// 切换表时重置选中的列
|
||||||
|
this.selectedColumns = [];
|
||||||
|
this.page = 1;
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
if (changedProperties.has('view') && this.tableName) {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
if (!this.tableName) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Initialize selected columns (all by default)
|
||||||
|
if (this.schema.fields) {
|
||||||
|
this.selectedColumns = this.schema.fields.map(f => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.view === 'data') {
|
||||||
|
await this.loadTableData();
|
||||||
|
} else if (this.view === 'manifest') {
|
||||||
|
await this.loadManifestData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
switchView(newView) {
|
||||||
|
this.view = newView;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleColumn(columnName) {
|
||||||
|
const index = this.selectedColumns.indexOf(columnName);
|
||||||
|
if (index > -1) {
|
||||||
|
this.selectedColumns = this.selectedColumns.filter(c => c !== columnName);
|
||||||
|
} else {
|
||||||
|
this.selectedColumns = [...this.selectedColumns, columnName];
|
||||||
|
}
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(delta) {
|
||||||
|
this.page = Math.max(1, this.page + delta);
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
|
||||||
|
changePageSize(newSize) {
|
||||||
|
this.pageSize = parseInt(newSize);
|
||||||
|
this.page = 1;
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToPage(pageNum) {
|
||||||
|
const num = parseInt(pageNum);
|
||||||
|
if (num > 0 && this.tableData && num <= this.tableData.totalPages) {
|
||||||
|
this.page = num;
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showRowDetail(seq) {
|
||||||
|
this.dispatchEvent(new CustomEvent('show-row-detail', {
|
||||||
|
detail: { tableName: this.tableName, seq },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLevel(level) {
|
||||||
|
const levelCard = this.shadowRoot.querySelector(`[data-level="${level}"]`);
|
||||||
|
if (levelCard) {
|
||||||
|
const fileList = levelCard.querySelector('.file-list');
|
||||||
|
const icon = levelCard.querySelector('.expand-icon');
|
||||||
|
if (fileList.classList.contains('expanded')) {
|
||||||
|
fileList.classList.remove('expanded');
|
||||||
|
icon.style.transform = 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
fileList.classList.add('expanded');
|
||||||
|
icon.style.transform = 'rotate(90deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCount(count) {
|
||||||
|
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
|
||||||
|
if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.tableName) {
|
||||||
|
return html`
|
||||||
|
<div class="empty">
|
||||||
|
<h2>Select a table to view data</h2>
|
||||||
|
<p>Choose a table from the sidebar to get started</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
return html`<div class="loading">Loading...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="content-wrapper">
|
||||||
|
${this.view === 'data' ? html`
|
||||||
|
<srdb-data-view
|
||||||
|
.tableName=${this.tableName}
|
||||||
|
.schema=${this.schema}
|
||||||
|
.tableData=${this.tableData}
|
||||||
|
.selectedColumns=${this.selectedColumns}
|
||||||
|
.loading=${this.loading}
|
||||||
|
@columns-changed=${(e) => {
|
||||||
|
this.selectedColumns = e.detail.columns;
|
||||||
|
this.loadTableData();
|
||||||
|
}}
|
||||||
|
@show-row-detail=${(e) => this.showRowDetail(e.detail.seq)}
|
||||||
|
></srdb-data-view>
|
||||||
|
` : html`
|
||||||
|
<srdb-manifest-view
|
||||||
|
.manifestData=${this.manifestData}
|
||||||
|
.loading=${this.loading}
|
||||||
|
></srdb-manifest-view>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.view === 'data' && this.tableData ? this.renderPagination() : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPagination() {
|
||||||
|
return html`
|
||||||
|
<div class="pagination">
|
||||||
|
<select @change=${(e) => this.changePageSize(e.target.value)}>
|
||||||
|
${[10, 20, 50, 100].map(size => html`
|
||||||
|
<option value="${size}" ?selected=${size === this.pageSize}>
|
||||||
|
${size} / page
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click=${() => this.changePage(-1)}
|
||||||
|
?disabled=${this.page <= 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Page ${this.page} of ${this.tableData.totalPages}
|
||||||
|
(${this.formatCount(this.tableData.totalRows)} rows)
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="${this.tableData.totalPages}"
|
||||||
|
placeholder="Jump to"
|
||||||
|
@keydown=${(e) => e.key === 'Enter' && this.jumpToPage(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button @click=${(e) => this.jumpToPage(e.target.previousElementSibling.value)}>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click=${() => this.changePage(1)}
|
||||||
|
?disabled=${this.page >= this.tableData.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCount(count) {
|
||||||
|
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
|
||||||
|
if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(delta) {
|
||||||
|
this.page = Math.max(1, this.page + delta);
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
|
||||||
|
changePageSize(newSize) {
|
||||||
|
this.pageSize = parseInt(newSize);
|
||||||
|
this.page = 1;
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToPage(pageNum) {
|
||||||
|
const num = parseInt(pageNum);
|
||||||
|
if (num > 0 && this.tableData && num <= this.tableData.totalPages) {
|
||||||
|
this.page = num;
|
||||||
|
this.loadTableData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showRowDetail(seq) {
|
||||||
|
this.dispatchEvent(new CustomEvent('show-row-detail', {
|
||||||
|
detail: { tableName: this.tableName, seq },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
showCellContent(content) {
|
||||||
|
this.dispatchEvent(new CustomEvent('show-cell-content', {
|
||||||
|
detail: { content },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-table-view', TableView);
|
||||||
123
webui/static/js/components/theme-toggle.js
Normal file
123
webui/static/js/components/theme-toggle.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export class ThemeToggle extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
theme: { type: String }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
sharedStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
// 从 localStorage 读取主题,默认为 dark
|
||||||
|
this.theme = localStorage.getItem('srdb-theme') || 'dark';
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('srdb-theme', this.theme);
|
||||||
|
this.applyTheme();
|
||||||
|
|
||||||
|
// 触发主题变化事件
|
||||||
|
this.dispatchEvent(new CustomEvent('theme-changed', {
|
||||||
|
detail: { theme: this.theme },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
if (this.theme === 'light') {
|
||||||
|
// 浅色主题
|
||||||
|
root.style.setProperty('--srdb-bg-main', '#ffffff');
|
||||||
|
root.style.setProperty('--srdb-bg-surface', '#f5f5f5');
|
||||||
|
root.style.setProperty('--srdb-bg-elevated', '#e5e5e5');
|
||||||
|
root.style.setProperty('--srdb-bg-hover', '#d4d4d4');
|
||||||
|
|
||||||
|
root.style.setProperty('--srdb-text-primary', '#1a1a1a');
|
||||||
|
root.style.setProperty('--srdb-text-secondary', '#666666');
|
||||||
|
root.style.setProperty('--srdb-text-tertiary', '#999999');
|
||||||
|
|
||||||
|
root.style.setProperty('--srdb-border-color', 'rgba(0, 0, 0, 0.1)');
|
||||||
|
root.style.setProperty('--srdb-border-hover', 'rgba(0, 0, 0, 0.2)');
|
||||||
|
|
||||||
|
root.style.setProperty('--srdb-shadow-sm', '0 1px 2px 0 rgba(0, 0, 0, 0.05)');
|
||||||
|
root.style.setProperty('--srdb-shadow-md', '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)');
|
||||||
|
root.style.setProperty('--srdb-shadow-lg', '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)');
|
||||||
|
root.style.setProperty('--srdb-shadow-xl', '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)');
|
||||||
|
} else {
|
||||||
|
// 深色主题(默认值)
|
||||||
|
root.style.setProperty('--srdb-bg-main', '#0f0f1a');
|
||||||
|
root.style.setProperty('--srdb-bg-surface', '#1a1a2e');
|
||||||
|
root.style.setProperty('--srdb-bg-elevated', '#222236');
|
||||||
|
root.style.setProperty('--srdb-bg-hover', '#2a2a3e');
|
||||||
|
|
||||||
|
root.style.setProperty('--srdb-text-primary', '#ffffff');
|
||||||
|
root.style.setProperty('--srdb-text-secondary', '#a0a0b0');
|
||||||
|
root.style.setProperty('--srdb-text-tertiary', '#6b6b7b');
|
||||||
|
|
||||||
|
root.style.setProperty('--srdb-border-color', 'rgba(255, 255, 255, 0.1)');
|
||||||
|
root.style.setProperty('--srdb-border-hover', 'rgba(255, 255, 255, 0.2)');
|
||||||
|
|
||||||
|
root.style.setProperty('--srdb-shadow-sm', '0 1px 2px 0 rgba(0, 0, 0, 0.3)');
|
||||||
|
root.style.setProperty('--srdb-shadow-md', '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3)');
|
||||||
|
root.style.setProperty('--srdb-shadow-lg', '0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3)');
|
||||||
|
root.style.setProperty('--srdb-shadow-xl', '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<button class="theme-toggle" @click=${this.toggleTheme}>
|
||||||
|
<span class="icon">
|
||||||
|
${this.theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</span>
|
||||||
|
<span class="label">
|
||||||
|
${this.theme === 'dark' ? 'Dark' : 'Light'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('srdb-theme-toggle', ThemeToggle);
|
||||||
89
webui/static/js/styles/shared-styles.js
Normal file
89
webui/static/js/styles/shared-styles.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
|
||||||
|
|
||||||
|
// 共享的基础样式
|
||||||
|
export const sharedStyles = css`
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条样式 */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用状态样式 */
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// CSS 变量(可以在组件中使用,优先使用外部定义的变量)
|
||||||
|
export const cssVariables = css`
|
||||||
|
:host {
|
||||||
|
/* 主色调 - 优雅的紫蓝色 */
|
||||||
|
--primary: var(--srdb-primary, #6366f1);
|
||||||
|
--primary-dark: var(--srdb-primary-dark, #4f46e5);
|
||||||
|
--primary-light: var(--srdb-primary-light, #818cf8);
|
||||||
|
--primary-bg: var(--srdb-primary-bg, rgba(99, 102, 241, 0.1));
|
||||||
|
|
||||||
|
/* 背景色 */
|
||||||
|
--bg-main: var(--srdb-bg-main, #0f0f1a);
|
||||||
|
--bg-surface: var(--srdb-bg-surface, #1a1a2e);
|
||||||
|
--bg-elevated: var(--srdb-bg-elevated, #222236);
|
||||||
|
--bg-hover: var(--srdb-bg-hover, #2a2a3e);
|
||||||
|
|
||||||
|
/* 文字颜色 */
|
||||||
|
--text-primary: var(--srdb-text-primary, #ffffff);
|
||||||
|
--text-secondary: var(--srdb-text-secondary, #a0a0b0);
|
||||||
|
--text-tertiary: var(--srdb-text-tertiary, #6b6b7b);
|
||||||
|
|
||||||
|
/* 边框和分隔线 */
|
||||||
|
--border-color: var(--srdb-border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
--border-hover: var(--srdb-border-hover, rgba(255, 255, 255, 0.2));
|
||||||
|
|
||||||
|
/* 状态颜色 */
|
||||||
|
--success: var(--srdb-success, #10b981);
|
||||||
|
--warning: var(--srdb-warning, #f59e0b);
|
||||||
|
--danger: var(--srdb-danger, #ef4444);
|
||||||
|
--info: var(--srdb-info, #3b82f6);
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-sm: var(--srdb-shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.3));
|
||||||
|
--shadow-md: var(--srdb-shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3));
|
||||||
|
--shadow-lg: var(--srdb-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3));
|
||||||
|
--shadow-xl: var(--srdb-shadow-xl, 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3));
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: var(--srdb-radius-sm, 6px);
|
||||||
|
--radius-md: var(--srdb-radius-md, 8px);
|
||||||
|
--radius-lg: var(--srdb-radius-lg, 12px);
|
||||||
|
--radius-xl: var(--srdb-radius-xl, 16px);
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition: var(--srdb-transition, all 0.2s cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
}
|
||||||
|
`;
|
||||||
373
webui/webui.go
373
webui/webui.go
@@ -5,12 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.tczkiot.com/srdb"
|
"code.tczkiot.com/srdb"
|
||||||
"code.tczkiot.com/srdb/sst"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
@@ -33,17 +34,10 @@ func NewWebUI(db *srdb.Database) *WebUI {
|
|||||||
func (ui *WebUI) setupHandler() http.Handler {
|
func (ui *WebUI) setupHandler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// API endpoints - JSON
|
// API endpoints - 纯 JSON API
|
||||||
mux.HandleFunc("/api/tables", ui.handleListTables)
|
mux.HandleFunc("/api/tables", ui.handleListTables)
|
||||||
mux.HandleFunc("/api/tables/", ui.handleTableAPI)
|
mux.HandleFunc("/api/tables/", ui.handleTableAPI)
|
||||||
|
|
||||||
// API endpoints - HTML (for htmx)
|
|
||||||
mux.HandleFunc("/api/tables-html", ui.handleTablesHTML)
|
|
||||||
mux.HandleFunc("/api/tables-view/", ui.handleTableViewHTML)
|
|
||||||
|
|
||||||
// Debug endpoint - list embedded files
|
|
||||||
mux.HandleFunc("/debug/files", ui.handleDebugFiles)
|
|
||||||
|
|
||||||
// 静态文件服务
|
// 静态文件服务
|
||||||
staticFiles, _ := fs.Sub(staticFS, "static")
|
staticFiles, _ := fs.Sub(staticFS, "static")
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles))))
|
||||||
@@ -105,6 +99,11 @@ func (ui *WebUI) handleListTables(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按表名排序
|
||||||
|
sort.Slice(tables, func(i, j int) bool {
|
||||||
|
return tables[i].Name < tables[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(tables)
|
json.NewEncoder(w).Encode(tables)
|
||||||
}
|
}
|
||||||
@@ -221,11 +220,8 @@ func (ui *WebUI) handleTableManifest(w http.ResponseWriter, r *http.Request, tab
|
|||||||
picker := compactionMgr.GetPicker()
|
picker := compactionMgr.GetPicker()
|
||||||
|
|
||||||
levels := make([]LevelInfo, 0)
|
levels := make([]LevelInfo, 0)
|
||||||
for level := 0; level < 7; level++ {
|
for level := range 7 {
|
||||||
files := version.GetLevel(level)
|
files := version.GetLevel(level)
|
||||||
if len(files) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSize := int64(0)
|
totalSize := int64(0)
|
||||||
fileInfos := make([]FileInfo, 0, len(files))
|
fileInfos := make([]FileInfo, 0, len(files))
|
||||||
@@ -241,7 +237,10 @@ func (ui *WebUI) handleTableManifest(w http.ResponseWriter, r *http.Request, tab
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
score := picker.GetLevelScore(version, level)
|
score := 0.0
|
||||||
|
if len(files) > 0 {
|
||||||
|
score = picker.GetLevelScore(version, level)
|
||||||
|
}
|
||||||
|
|
||||||
levels = append(levels, LevelInfo{
|
levels = append(levels, LevelInfo{
|
||||||
Level: level,
|
Level: level,
|
||||||
@@ -294,12 +293,10 @@ func (ui *WebUI) handleTableDataBySeq(w http.ResponseWriter, r *http.Request, ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构造响应(不进行剪裁,返回完整数据)
|
// 构造响应(不进行剪裁,返回完整数据)
|
||||||
rowData := make(map[string]interface{})
|
rowData := make(map[string]any)
|
||||||
rowData["_seq"] = row.Seq
|
rowData["_seq"] = row.Seq
|
||||||
rowData["_time"] = row.Time
|
rowData["_time"] = row.Time
|
||||||
for k, v := range row.Data {
|
maps.Copy(rowData, row.Data)
|
||||||
rowData[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(rowData)
|
json.NewEncoder(w).Encode(rowData)
|
||||||
@@ -342,26 +339,52 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa
|
|||||||
var selectedFields []string
|
var selectedFields []string
|
||||||
if selectParam != "" {
|
if selectParam != "" {
|
||||||
selectedFields = strings.Split(selectParam, ",")
|
selectedFields = strings.Split(selectParam, ",")
|
||||||
|
// 清理字段名(去除空格)
|
||||||
|
for i := range selectedFields {
|
||||||
|
selectedFields[i] = strings.TrimSpace(selectedFields[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 schema 用于字段类型判断
|
// 获取 schema 用于字段类型判断
|
||||||
tableSchema := table.GetSchema()
|
tableSchema := table.GetSchema()
|
||||||
|
|
||||||
// 使用 Query API 获取所有数据(高效)
|
// 使用 Query API 获取数据,如果指定了字段则只查询指定字段(按字段压缩优化)
|
||||||
queryRows, err := table.Query().Rows()
|
queryBuilder := table.Query()
|
||||||
|
if len(selectedFields) > 0 {
|
||||||
|
// 确保 _seq 和 _time 总是被查询(用于构造响应)
|
||||||
|
fieldsWithMeta := make([]string, 0, len(selectedFields)+2)
|
||||||
|
hasSeq := false
|
||||||
|
hasTime := false
|
||||||
|
for _, field := range selectedFields {
|
||||||
|
switch field {
|
||||||
|
case "_seq":
|
||||||
|
hasSeq = true
|
||||||
|
case "_time":
|
||||||
|
hasTime = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSeq {
|
||||||
|
fieldsWithMeta = append(fieldsWithMeta, "_seq")
|
||||||
|
}
|
||||||
|
if !hasTime {
|
||||||
|
fieldsWithMeta = append(fieldsWithMeta, "_time")
|
||||||
|
}
|
||||||
|
fieldsWithMeta = append(fieldsWithMeta, selectedFields...)
|
||||||
|
|
||||||
|
queryBuilder = queryBuilder.Select(fieldsWithMeta...)
|
||||||
|
}
|
||||||
|
queryRows, err := queryBuilder.Rows()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to query table: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to query table: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer queryRows.Close()
|
defer queryRows.Close()
|
||||||
|
|
||||||
// 收集所有 rows 到内存中用于分页(对于大数据集,后续可以优化为流式处理)
|
// 收集所有 rows 到内存中用于分页
|
||||||
allRows := make([]*sst.Row, 0)
|
allRows := make([]*srdb.SSTableRow, 0)
|
||||||
for queryRows.Next() {
|
for queryRows.Next() {
|
||||||
row := queryRows.Row()
|
row := queryRows.Row()
|
||||||
// Row 是 query.Row 类型,需要获取其内部的 sst.Row
|
sstRow := &srdb.SSTableRow{
|
||||||
// 直接构造 sst.Row
|
|
||||||
sstRow := &sst.Row{
|
|
||||||
Seq: row.Data()["_seq"].(int64),
|
Seq: row.Data()["_seq"].(int64),
|
||||||
Time: row.Data()["_time"].(int64),
|
Time: row.Data()["_time"].(int64),
|
||||||
Data: make(map[string]any),
|
Data: make(map[string]any),
|
||||||
@@ -378,57 +401,28 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa
|
|||||||
// 计算分页
|
// 计算分页
|
||||||
totalRows := int64(len(allRows))
|
totalRows := int64(len(allRows))
|
||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
end := offset + pageSize
|
end := min(offset+pageSize, int(totalRows))
|
||||||
if end > int(totalRows) {
|
|
||||||
end = int(totalRows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前页数据
|
// 获取当前页数据
|
||||||
rows := make([]*sst.Row, 0, pageSize)
|
rows := make([]*srdb.SSTableRow, 0, pageSize)
|
||||||
if offset < int(totalRows) {
|
if offset < int(totalRows) {
|
||||||
rows = allRows[offset:end]
|
rows = allRows[offset:end]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造响应,对 string 字段进行剪裁
|
// 构造响应,对 string 字段进行剪裁
|
||||||
const maxStringLength = 100 // 最大字符串长度(按字符计数,非字节)
|
const maxStringLength = 100 // 最大字符串长度
|
||||||
data := make([]map[string]any, 0, len(rows))
|
data := make([]map[string]any, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
rowData := make(map[string]any)
|
rowData := make(map[string]any)
|
||||||
|
rowData["_seq"] = row.Seq
|
||||||
|
rowData["_time"] = row.Time
|
||||||
|
|
||||||
// 如果指定了字段,只返回选定的字段
|
// 遍历所有字段
|
||||||
if len(selectedFields) > 0 {
|
|
||||||
for _, field := range selectedFields {
|
|
||||||
field = strings.TrimSpace(field)
|
|
||||||
if field == "_seq" {
|
|
||||||
rowData["_seq"] = row.Seq
|
|
||||||
} else if field == "_time" {
|
|
||||||
rowData["_time"] = row.Time
|
|
||||||
} else if v, ok := row.Data[field]; ok {
|
|
||||||
// 检查字段类型
|
|
||||||
fieldDef, err := tableSchema.GetField(field)
|
|
||||||
if err == nil && fieldDef.Type == srdb.FieldTypeString {
|
|
||||||
// 对字符串字段进行剪裁
|
|
||||||
if str, ok := v.(string); ok {
|
|
||||||
runes := []rune(str)
|
|
||||||
if len(runes) > maxStringLength {
|
|
||||||
rowData[field] = string(runes[:maxStringLength]) + "..."
|
|
||||||
rowData[field+"_truncated"] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rowData[field] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 返回所有字段
|
|
||||||
rowData["_seq"] = row.Seq
|
|
||||||
rowData["_time"] = row.Time
|
|
||||||
for k, v := range row.Data {
|
for k, v := range row.Data {
|
||||||
// 检查字段类型
|
// 检查字段类型
|
||||||
field, err := tableSchema.GetField(k)
|
field, err := tableSchema.GetField(k)
|
||||||
if err == nil && field.Type == srdb.FieldTypeString {
|
if err == nil && field.Type == srdb.FieldTypeString {
|
||||||
// 对字符串字段进行剪裁(按 rune 截取,避免 CJK 等多字节字符乱码)
|
// 对字符串字段进行剪裁
|
||||||
if str, ok := v.(string); ok {
|
if str, ok := v.(string); ok {
|
||||||
runes := []rune(str)
|
runes := []rune(str)
|
||||||
if len(runes) > maxStringLength {
|
if len(runes) > maxStringLength {
|
||||||
@@ -440,11 +434,10 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa
|
|||||||
}
|
}
|
||||||
rowData[k] = v
|
rowData[k] = v
|
||||||
}
|
}
|
||||||
}
|
|
||||||
data = append(data, rowData)
|
data = append(data, rowData)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]any{
|
||||||
"data": data,
|
"data": data,
|
||||||
"page": page,
|
"page": page,
|
||||||
"pageSize": pageSize,
|
"pageSize": pageSize,
|
||||||
@@ -456,25 +449,6 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa
|
|||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDebugFiles 列出所有嵌入的文件(调试用)
|
|
||||||
func (ui *WebUI) handleDebugFiles(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
fmt.Fprintln(w, "Embedded files in staticFS:")
|
|
||||||
fs.WalkDir(staticFS, ".", func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(w, "ERROR walking %s: %v\n", path, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
fmt.Fprintf(w, "[DIR] %s/\n", path)
|
|
||||||
} else {
|
|
||||||
info, _ := d.Info()
|
|
||||||
fmt.Fprintf(w, "[FILE] %s (%d bytes)\n", path, info.Size())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleIndex 处理首页请求
|
// handleIndex 处理首页请求
|
||||||
func (ui *WebUI) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (ui *WebUI) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
@@ -493,238 +467,3 @@ func (ui *WebUI) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write(content)
|
w.Write(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTablesHTML 处理获取表列表 HTML 请求(for htmx)
|
|
||||||
func (ui *WebUI) handleTablesHTML(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allTables := ui.db.GetAllTablesInfo()
|
|
||||||
tables := make([]TableListItem, 0, len(allTables))
|
|
||||||
for name, table := range allTables {
|
|
||||||
schema := table.GetSchema()
|
|
||||||
fields := make([]FieldInfo, 0, len(schema.Fields))
|
|
||||||
for _, field := range schema.Fields {
|
|
||||||
fields = append(fields, FieldInfo{
|
|
||||||
Name: field.Name,
|
|
||||||
Type: field.Type.String(),
|
|
||||||
Indexed: field.Indexed,
|
|
||||||
Comment: field.Comment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tables = append(tables, TableListItem{
|
|
||||||
Name: name,
|
|
||||||
CreatedAt: table.GetCreatedAt(),
|
|
||||||
Fields: fields,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
html := renderTablesHTML(tables)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTableViewHTML 处理获取表视图 HTML 请求(for htmx)
|
|
||||||
func (ui *WebUI) handleTableViewHTML(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析路径: /api/tables-view/{name} 或 /api/tables-view/{name}/manifest
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/tables-view/")
|
|
||||||
parts := strings.Split(path, "/")
|
|
||||||
|
|
||||||
if len(parts) < 1 || parts[0] == "" {
|
|
||||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tableName := parts[0]
|
|
||||||
isManifest := len(parts) >= 2 && parts[1] == "manifest"
|
|
||||||
|
|
||||||
table, err := ui.db.GetTable(tableName)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isManifest {
|
|
||||||
// 返回 Manifest 视图 HTML
|
|
||||||
ui.renderManifestHTML(w, r, tableName, table)
|
|
||||||
} else {
|
|
||||||
// 返回 Data 视图 HTML
|
|
||||||
ui.renderDataHTML(w, r, tableName, table)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDataHTML 渲染数据视图 HTML
|
|
||||||
func (ui *WebUI) renderDataHTML(w http.ResponseWriter, r *http.Request, tableName string, table *srdb.Table) {
|
|
||||||
// 解析分页参数
|
|
||||||
pageStr := r.URL.Query().Get("page")
|
|
||||||
pageSizeStr := r.URL.Query().Get("pageSize")
|
|
||||||
|
|
||||||
page := 1
|
|
||||||
pageSize := 20
|
|
||||||
|
|
||||||
if pageStr != "" {
|
|
||||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
||||||
page = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pageSizeStr != "" {
|
|
||||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 1000 {
|
|
||||||
pageSize = ps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 schema
|
|
||||||
tableSchema := table.GetSchema()
|
|
||||||
schemaInfo := SchemaInfo{
|
|
||||||
Name: tableSchema.Name,
|
|
||||||
Fields: make([]FieldInfo, 0, len(tableSchema.Fields)),
|
|
||||||
}
|
|
||||||
for _, field := range tableSchema.Fields {
|
|
||||||
schemaInfo.Fields = append(schemaInfo.Fields, FieldInfo{
|
|
||||||
Name: field.Name,
|
|
||||||
Type: field.Type.String(),
|
|
||||||
Indexed: field.Indexed,
|
|
||||||
Comment: field.Comment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 Query API 获取所有数据
|
|
||||||
queryRows, err := table.Query().Rows()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to query table: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer queryRows.Close()
|
|
||||||
|
|
||||||
// 收集所有 rows
|
|
||||||
allRows := make([]*sst.Row, 0)
|
|
||||||
for queryRows.Next() {
|
|
||||||
row := queryRows.Row()
|
|
||||||
sstRow := &sst.Row{
|
|
||||||
Seq: row.Data()["_seq"].(int64),
|
|
||||||
Time: row.Data()["_time"].(int64),
|
|
||||||
Data: make(map[string]any),
|
|
||||||
}
|
|
||||||
for k, v := range row.Data() {
|
|
||||||
if k != "_seq" && k != "_time" {
|
|
||||||
sstRow.Data[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allRows = append(allRows, sstRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算分页
|
|
||||||
totalRows := int64(len(allRows))
|
|
||||||
offset := (page - 1) * pageSize
|
|
||||||
end := offset + pageSize
|
|
||||||
if end > int(totalRows) {
|
|
||||||
end = int(totalRows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前页数据
|
|
||||||
rows := make([]*sst.Row, 0, pageSize)
|
|
||||||
if offset < int(totalRows) {
|
|
||||||
rows = allRows[offset:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构造 TableDataResponse
|
|
||||||
const maxStringLength = 100
|
|
||||||
data := make([]map[string]any, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
rowData := make(map[string]any)
|
|
||||||
rowData["_seq"] = row.Seq
|
|
||||||
rowData["_time"] = row.Time
|
|
||||||
for k, v := range row.Data {
|
|
||||||
field, err := tableSchema.GetField(k)
|
|
||||||
if err == nil && field.Type == srdb.FieldTypeString {
|
|
||||||
if str, ok := v.(string); ok {
|
|
||||||
runes := []rune(str)
|
|
||||||
if len(runes) > maxStringLength {
|
|
||||||
rowData[k] = string(runes[:maxStringLength]) + "..."
|
|
||||||
rowData[k+"_truncated"] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rowData[k] = v
|
|
||||||
}
|
|
||||||
data = append(data, rowData)
|
|
||||||
}
|
|
||||||
|
|
||||||
tableData := TableDataResponse{
|
|
||||||
Data: data,
|
|
||||||
Page: int64(page),
|
|
||||||
PageSize: int64(pageSize),
|
|
||||||
TotalRows: totalRows,
|
|
||||||
TotalPages: (totalRows + int64(pageSize) - 1) / int64(pageSize),
|
|
||||||
}
|
|
||||||
|
|
||||||
html := renderDataViewHTML(tableName, schemaInfo, tableData)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderManifestHTML 渲染 Manifest 视图 HTML
|
|
||||||
func (ui *WebUI) renderManifestHTML(w http.ResponseWriter, r *http.Request, tableName string, table *srdb.Table) {
|
|
||||||
engine := table.GetEngine()
|
|
||||||
versionSet := engine.GetVersionSet()
|
|
||||||
version := versionSet.GetCurrent()
|
|
||||||
|
|
||||||
// 获取 Compaction Manager 和 Picker
|
|
||||||
compactionMgr := engine.GetCompactionManager()
|
|
||||||
picker := compactionMgr.GetPicker()
|
|
||||||
|
|
||||||
levels := make([]LevelInfo, 0)
|
|
||||||
for level := 0; level < 7; level++ {
|
|
||||||
files := version.GetLevel(level)
|
|
||||||
|
|
||||||
totalSize := int64(0)
|
|
||||||
fileInfos := make([]FileInfo, 0, len(files))
|
|
||||||
for _, f := range files {
|
|
||||||
totalSize += f.FileSize
|
|
||||||
fileInfos = append(fileInfos, FileInfo{
|
|
||||||
FileNumber: f.FileNumber,
|
|
||||||
Level: f.Level,
|
|
||||||
FileSize: f.FileSize,
|
|
||||||
MinKey: f.MinKey,
|
|
||||||
MaxKey: f.MaxKey,
|
|
||||||
RowCount: f.RowCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
score := 0.0
|
|
||||||
if len(files) > 0 {
|
|
||||||
score = picker.GetLevelScore(version, level)
|
|
||||||
}
|
|
||||||
|
|
||||||
levels = append(levels, LevelInfo{
|
|
||||||
Level: level,
|
|
||||||
FileCount: len(files),
|
|
||||||
TotalSize: totalSize,
|
|
||||||
Score: score,
|
|
||||||
Files: fileInfos,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := compactionMgr.GetStats()
|
|
||||||
|
|
||||||
manifest := ManifestResponse{
|
|
||||||
Levels: levels,
|
|
||||||
NextFileNumber: versionSet.GetNextFileNumber(),
|
|
||||||
LastSequence: versionSet.GetLastSequence(),
|
|
||||||
CompactionStats: stats,
|
|
||||||
}
|
|
||||||
|
|
||||||
html := renderManifestViewHTML(tableName, manifest)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user