Compare commits

...

10 Commits

Author SHA1 Message Date
6fb0731935 重构:为核心组件实现 Reset 方法优化重置机制
为所有核心组件添加 Reset() 方法:
- LogWriter.Reset(): 删除并重新创建日志文件,保持 index 和 wbuf 引用不变
- RecordIndex.Reset(): 清空索引数据并重新创建索引文件
- RecordQuery.Reset(): 关闭并重新打开日志文件
- ProcessCursor.Reset(): 删除位置文件并重置游标位置
- LogTailer.Reset(): 重置内部 channel 状态

优化 TopicProcessor.Reset() 实现:
- 不再销毁和重建组件对象
- 通过调用各组件的 Reset() 方法重置状态
- 保持组件间引用关系稳定
- 减少代码行数约 20 行
- 避免空指针风险和内存分配开销

代码改进:
- LogWriter 添加 path 字段用于重置
- 移除 topic_processor.go 中未使用的 os import
- 职责分离更清晰,每个组件管理自己的重置逻辑

测试结果:
- TestTopicReset: PASS
- TestTopicResetWithPendingRecords: PASS
- 所有 TopicProcessor 相关测试通过

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 21:58:54 +08:00
bcc328b129 重构:TopicProcessor 状态管理系统与 Reset 方法优化
新增功能:
- 添加 ProcessorState 状态类型(Idle/Starting/Running/Stopping/Stopped/Resetting/Error)
- 添加 ProcessorStatus 结构体和状态管理方法(GetState/GetStatus/setState)
- 实现状态转换逻辑和访问控制(CanWrite/CanQuery)
- 新增 CanReset() 方法检查是否可执行重置操作

Reset 方法优化:
- 重写 Reset() 方法,不再停止 processor
- 只有在无待处理记录时才能执行重置
- 进入 Resetting 状态期间阻止所有读写操作
- 重置后自动恢复到之前的运行状态
- 正确关闭并重置 cursor 和 stats 组件
- 调整执行顺序:先关闭组件,再删除文件,后重新初始化

错误处理增强:
- 添加 ErrProcessorResetting 和 ErrInvalidState 错误类型
- 添加 EventStateChanged 事件类型
- 修复 writer/index 为 nil 时的空指针问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 18:56:52 +08:00
810664eb12 重构:优化记录格式并修复核心功能
- 修改记录存储格式为 [4B len][8B offset][4B CRC][16B UUID][data]
- 修复 TopicProcessor 中 WaitGroup 使用错误导致 handler 不执行的问题
- 修复写入保护逻辑,避免 dirtyOffset=-1 时误判为写入中
- 添加统计信息定期持久化功能
- 改进 UTF-8 字符截断处理,防止 CJK 字符乱码
- 优化 Web UI:显示人类可读的文件大小,支持点击外部关闭弹窗
- 重构示例代码,添加 webui 和 webui_integration 示例

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 17:54:49 +08:00
955a467248 优化:减少代码重复和内存分配
主要优化:

1. 提取重复代码(topic_processor.go)
   - 新增 addStatusToRecords() 辅助方法
   - QueryOldest 和 QueryNewest 中的状态添加逻辑重复,已提取
   - 减少 38 行重复代码

2. 优化内存分配(index.go)
   - 在 RecordIndex 结构体中添加可重用的 entryBuf
   - Append 方法不再每次都分配 8 字节 buffer
   - 高频写入场景下可显著减少 GC 压力

性能提升:
- 减少内存分配次数(每次写入索引节省 1 次分配)
- 提高代码可维护性(消除重复代码)
- 所有测试通过 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 13:32:44 +08:00
90cc9e21c9 重构:重命名核心组件并增强查询功能
主要更改:

1. 核心重命名
   - Seqlog -> LogHub (更准确地反映其作为日志中枢的角色)
   - NewSeqlog() -> NewLogHub()
   - LogCursor -> ProcessCursor (更准确地反映其用于处理场景)
   - seqlog_manager.go -> loghub.go (文件名与结构体名对应)

2. TopicProcessor.Reset 增强
   - 如果正在运行且没有待处理的日志,会自动停止后重置
   - 如果有待处理的日志,返回详细错误(显示已处理/总记录数)
   - 简化了 LogHub.ResetTopic,移除显式 Stop 调用

3. 新增查询方法
   - TopicProcessor.QueryFromFirst(count) - 从第一条记录向索引递增方向查询
   - TopicProcessor.QueryFromLast(count) - 从最后一条记录向索引递减方向查询
   - LogHub.QueryFromFirst(topic, count)
   - LogHub.QueryFromLast(topic, count)

4. 测试覆盖
   - 添加 query_test.go - QueryFromProcessing 测试
   - 添加 TestQueryFromFirstAndLast - TopicProcessor 查询测试
   - 添加 TestLogHubQueryFromFirstAndLast - LogHub 查询测试
   - 添加 TestTopicResetWithPendingRecords - Reset 增强功能测试

5. 示例代码
   - 添加 example/get_record/ - 演示 QueryFromProcessing 用法
   - 更新所有示例以使用 LogHub 和新 API

所有测试通过 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 13:26:21 +08:00
dfdc27c67f 修复:统一查询方法的返回顺序为索引递增
重要变更:
- QueryOldest 和 QueryNewest 现在都返回按索引递增排序的结果
- 移除了 QueryNewest 中的结果反转操作(line 184-187)

方法行为说明:
- QueryOldest(startIndex, count): 从 startIndex 向索引递增方向查询
- QueryNewest(endIndex, count): 从 endIndex 向索引递减方向查询
- 两者返回结果都按索引递增方向排序(一致性)

更新内容:
1. query.go:
   - 移除 QueryNewest 的反转操作
   - 更新两个方法的注释
2. topic_processor.go: 更新注释与实现一致
3. seqlog_test.go: 更新测试预期结果
4. example/index/main.go: 更新注释和输出说明

测试验证:
- 所有测试通过(go test ./... -short)
- 示例编译成功

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 11:55:44 +08:00
9b7a9c2734 更新:.gitignore 添加示例程序的编译产物
添加忽略规则:
- example/concurrent/concurrent
- example/index/index
- example/topic_processor/topic_processor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 11:49:21 +08:00
5379ec3e90 修复:webapp 翻页逻辑和编译错误
前端改进:
- 添加 direction 追踪翻页方向(forward/backward)
- 使用 startIndex/endIndex 追踪当前显示范围
- topics 列表自动排序
- 移除 currentCenterIndex,改用更清晰的边界索引

后端改进:
- 支持 direction 参数控制查询方向
- backward:从 startIndex 向前查询(更早的记录)
- forward:从 endIndex 向后查询(更新的记录)
- 修复 QueryNewest/QueryOldest 返回值处理
- 正确计算 startRecordIndex(基于查询结果)

修复编译错误:
- example/index/main.go:修正 QueryNewest/QueryOldest 返回值数量
- example/webapp/main.go:修正变量重复定义和未定义错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 11:48:36 +08:00
92063e581f 清理:移除冗余的 RUN_CONCURRENT.md
该文件内容已合并到 example/README.md 中,不再需要单独维护。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 02:05:24 +08:00
6862de12ff 新增:统一的错误类型系统 (errors.go)
主要功能:
- 定义哨兵错误(Sentinel Errors):ErrNilParameter, ErrInvalidCount,
  ErrInvalidRange, ErrAlreadyRunning, ErrNotFound, ErrCRCMismatch 等
- 实现结构化错误类型:TopicError, FileError, IndexError, ValidationError
- 提供错误检查辅助函数:IsTopicNotFound, IsIndexOutOfRange, IsCRCMismatch
- 支持 errors.Is 和 errors.As 进行错误判断

更新相关文件使用新错误类型:
- cursor.go: 使用 ValidationError 和 ErrCRCMismatch
- index.go: 使用 IndexError 处理索引越界
- query.go: 使用 ValidationError 验证参数
- seqlog_manager.go: 使用 TopicError 和 ErrAlreadyRegistered
- topic_processor.go: 使用 ErrAlreadyRunning 和 ErrInvalidConfig

测试覆盖:
- errors_test.go 提供完整的错误类型测试
- 所有现有测试继续通过

使用示例:
```go
// 检查 topic 是否存在
if IsTopicNotFound(err) {
    // 处理 topic 不存在的情况
}

// 检查索引越界
if IsIndexOutOfRange(err) {
    var indexErr *IndexError
    errors.As(err, &indexErr)
    fmt.Printf("index %d out of range\n", indexErr.Index)
}
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 01:56:22 +08:00
29 changed files with 3198 additions and 1517 deletions

3
.gitignore vendored
View File

@@ -22,6 +22,9 @@ test_*
# 示例程序编译产物
example/webapp/webapp
example/webapp/logs/
example/concurrent/concurrent
example/index/index
example/topic_processor/topic_processor
example/test_*/
examples/

View File

@@ -1,321 +0,0 @@
# Seqlog 索引设计文档
## 概述
seqlog 现已支持**持久化索引文件**,实现高效的日志记录查询和检索。
## 设计原则
1. **职责分离**:数据文件只存储数据,索引文件负责 offset 管理
2. **启动时重建**:每次启动都从日志文件重建索引,确保一致性
3. **最小化存储**:移除冗余字段,优化存储空间
## 索引文件格式
### 文件命名
```
{logfile}.idx
```
例如:`app.log` 对应的索引文件为 `app.log.idx`
### 数据文件结构
```
每条记录:[4B len][4B CRC][16B UUID][data]
头部大小24 字节
示例:
00000000 0f 00 00 00 8b 54 b3 a5 a5 9b fb 59 dd d5 45 2c |.....T.....Y..E,|
↑ Len=15 ↑ CRC ↑ UUID 开始...
00000010 a1 82 6f 16 5c 54 94 8d e6 97 a5 e5 bf 97 e8 ae |..o.\T..........|
↑ ...UUID 继续 ↑ 数据开始
```
### 索引文件结构
```
┌─────────────────────────────────────────────────┐
│ Header (8 字节) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Magic: [4B] 0x53494458 ("SIDX") │ │
│ │ Version: [4B] 1 │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ Index Entries (每条 8 字节) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Offset1: [8B] 第 1 条记录的偏移 │ │
│ │ Offset2: [8B] 第 2 条记录的偏移 │ │
│ │ ... │ │
│ │ OffsetN: [8B] 第 N 条记录的偏移 │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
RecordCount = (文件大小 - 8) / 8
LastOffset = 读取最后一条索引条目
```
### 实际示例
15 条记录的文件:
**数据文件** (591 字节)
```
00000000 0f 00 00 00 8b 54 b3 a5 a5 9b fb 59 dd d5 45 2c |.....T.....Y..E,|
00000010 a1 82 6f 16 5c 54 94 8d e6 97 a5 e5 bf 97 e8 ae |..o.\T..........|
↑ 数据:"日志记录 #1"
```
**索引文件** (128 字节)
```
00000000 53 49 44 58 01 00 00 00 00 00 00 00 00 00 00 00 |SIDX............|
↑ Magic="SIDX" ↑ Ver=1 ↑ Offset[0]=0
00000010 27 00 00 00 00 00 00 00 4e 00 00 00 00 00 00 00 |'.......N.......|
↑ Offset[1]=39 (0x27) ↑ Offset[2]=78 (0x4E)
文件大小128 字节 = 8B header + 15 × 8B entries
记录总数:(128 - 8) / 8 = 15 条
```
## 核心组件
### 1. RecordIndex (index.go)
索引文件管理器,负责索引的构建、加载、追加和查询。
#### 主要方法
```go
// 创建或加载索引(自动重建)
index, err := seqlog.NewRecordIndex(logPath)
// 追加索引条目(写入时调用)
err := index.Append(offset)
// 根据索引位置获取记录偏移
offset, err := index.GetOffset(index)
// 二分查找:根据偏移量查找索引位置
idx := index.FindIndex(offset)
// 获取记录总数
count := index.Count()
// 获取最后一条记录偏移
lastOffset := index.LastOffset()
// 关闭索引
index.Close()
```
### 2. LogWriter 集成
写入器支持可选的索引自动更新。
```go
// 创建带索引的写入器
writer, err := seqlog.NewLogWriterWithIndex(logPath, true)
// 写入时自动更新索引
offset, err := writer.Append([]byte("log data"))
// 关闭写入器和索引
writer.Close()
```
### 3. RecordQuery 集成
查询器优先使用索引文件进行高效查询。
```go
// 创建带索引的查询器
query, err := seqlog.NewRecordQueryWithIndex(logPath, true)
// 获取记录总数从索引O(1)
count, err := query.GetRecordCount()
// 向后查询基于索引O(log n) 定位 + O(n) 读取)
backward, err := query.QueryAt(offset, -1, count, startPos, endPos)
query.Close()
```
## 性能优化
### 1. 启动时重建
- **每次启动都重建**:从日志文件扫描构建索引,确保索引和日志完全一致
- **无损坏风险**:索引文件即使损坏也会自动重建
- **简化设计**:无需在头部保存 RecordCount 和 LastOffset
### 2. 增量更新
- 写入记录时同步追加索引条目
- 避免每次查询都重新扫描日志文件
### 3. 二分查找
- `FindIndex()` 使用二分查找定位偏移量
- 时间复杂度O(log n)
### 4. 自动恢复
- 索引文件损坏时自动重建
- 写入器打开时检查并同步索引
## 使用场景
### 场景 1高频查询
```go
// 使用索引,避免每次查询都扫描日志
query, _ := seqlog.NewRecordQueryWithIndex(logPath, true)
for i := 0; i < 1000; i++ {
count, _ := query.GetRecordCount() // O(1)
// ...
}
```
### 场景 2向后查询
```go
// 向后查询需要索引(否则需全文扫描)
backward, _ := query.QueryAt(currentPos, -1, 10, startPos, endPos)
```
### 场景 3断点续传
```go
// 程序重启后,索引自动加载,无需重建
index, _ := seqlog.NewRecordIndex(logPath)
count := index.Count()
lastOffset := index.LastOffset()
```
### 场景 4大文件处理
```go
// 索引文件远小于日志文件,快速加载
// 100 万条记录的索引文件仅 ~7.6 MB
// (24B header + 1,000,000 * 8B = 8,000,024 字节)
```
## API 兼容性
### 向后兼容
- 现有 API 保持不变(`NewLogWriter`, `NewRecordQuery`
- 默认**不启用**索引,避免影响现有代码
### 选择性启用
```go
// 旧代码:不使用索引
writer, _ := seqlog.NewLogWriter(logPath)
// 新代码:启用索引
writer, _ := seqlog.NewLogWriterWithIndex(logPath, true)
```
## 测试覆盖
所有索引功能均有完整测试覆盖:
```bash
go test -v -run TestIndex
```
测试用例:
- `TestIndexBasicOperations` - 基本操作(构建、加载、查询)
- `TestIndexRebuild` - 索引重建
- `TestQueryWithIndex` - 带索引的查询
- `TestIndexAppend` - 索引追加
- `TestIndexHeader` - 头部信息验证
## 文件示例
运行示例程序:
```bash
cd example
go run index_example.go
```
示例输出:
```
=== 示例 1带索引的写入器 ===
写入: offset=0, data=日志记录 #1
写入: offset=47, data=日志记录 #2
...
索引文件已创建: test_seqlog/app.log.idx
=== 示例 2带索引的查询器 ===
记录总数: 10
第 5 条记录的偏移: 235
向后查询 3 条记录:
[0] 状态=StatusProcessing, 数据=日志记录 #3
...
```
## 技术细节
### 存储开销
**数据文件**
- 每条记录头部24 字节(原 32 字节,节省 25%
- 格式:`[4B len][4B CRC][16B UUID][data]`
**索引文件**
- 头部8 字节(固定)
- 每条记录8 字节
- 总大小:`8 + recordCount * 8` 字节
**示例对比**1 万条记录,每条 100 字节数据):
| 组件 | 旧格式 (32B 头) | 新格式 (24B 头) | 节省 |
|------|----------------|----------------|------|
| 数据文件 | 1.32 MB | 1.24 MB | **80 KB (6%)** |
| 索引文件 | 128 字节 | 128 字节 | 0 |
| 总计 | 1.32 MB | 1.24 MB | **80 KB** |
### 性能对比
| 操作 | 无索引 | 有索引 |
|------|--------|--------|
| 获取记录总数 | O(n) 全文扫描 | O(1) 读取头部 |
| 向后查询定位 | 不支持 | O(log n) 二分查找 |
| 启动时间 | 快(无需加载) | 中(加载索引) |
| 内存占用 | 低 | 中(索引数组) |
### 数据一致性
- **启动时重建**:确保索引永远和日志文件一致
- 运行时:写入日志后立即追加索引
- 索引文件使用 `Sync()` 确保持久化
### 错误处理
- 日志文件不存在 → 返回错误
- 写入失败 → 返回错误,不更新索引
- 索引文件损坏 → 启动时自动重建(无影响)
## 未来优化方向
1. **稀疏索引**:每 N 条记录建一个索引点,减少内存占用
2. **分段索引**:大文件分段存储,支持并发查询
3. **压缩索引**:使用差值编码减少存储空间
4. **mmap 映射**:大索引文件使用内存映射优化加载
5. **布隆过滤器**:快速判断记录是否存在
## 总结
索引文件设计要点:
**持久化**:索引保存到磁盘,重启后快速加载
**增量更新**:写入时自动追加,避免重建
**向后兼容**:不影响现有 API可选启用
**自动恢复**:损坏时自动重建,确保可用性
**高效查询**:二分查找 + O(1) 元数据读取
**测试完备**:全面的单元测试覆盖

View File

@@ -10,8 +10,8 @@ import (
"github.com/google/uuid"
)
// LogCursor 日志游标(窗口模式)
type LogCursor struct {
// ProcessCursor 日志游标(窗口模式)
type ProcessCursor struct {
fd *os.File
rbuf []byte // 8 MiB 复用
path string // 日志文件路径
@@ -19,20 +19,22 @@ type LogCursor struct {
startIdx int // 窗口开始索引(已处理的记录索引)
endIdx int // 窗口结束索引(当前读到的记录索引)
index *RecordIndex // 索引管理器(来自外部)
writer *LogWriter // 写入器引用(用于检查写入位置)
}
// NewCursor 创建一个新的日志游标
// index: 外部提供的索引管理器,用于快速定位记录
func NewCursor(path string, index *RecordIndex) (*LogCursor, error) {
// writer: 外部提供的写入器引用,用于检查写入位置(可选,为 nil 时不进行写入保护检查)
func NewCursor(path string, index *RecordIndex, writer *LogWriter) (*ProcessCursor, error) {
if index == nil {
return nil, fmt.Errorf("index cannot be nil")
return nil, NewValidationError("index", "index cannot be nil", ErrNilParameter)
}
fd, err := os.Open(path)
if err != nil {
return nil, err
}
c := &LogCursor{
c := &ProcessCursor{
fd: fd,
rbuf: make([]byte, 8<<20),
path: path,
@@ -40,6 +42,7 @@ func NewCursor(path string, index *RecordIndex) (*LogCursor, error) {
startIdx: 0,
endIdx: 0,
index: index,
writer: writer,
}
// 尝试恢复上次位置
c.loadPosition()
@@ -47,12 +50,12 @@ func NewCursor(path string, index *RecordIndex) (*LogCursor, error) {
}
// Seek 到任意 offset支持重启续传
func (c *LogCursor) Seek(offset int64, whence int) (int64, error) {
func (c *ProcessCursor) Seek(offset int64, whence int) (int64, error) {
return c.fd.Seek(offset, whence)
}
// Next 读取下一条记录(使用索引快速定位)
func (c *LogCursor) Next() (*Record, error) {
func (c *ProcessCursor) Next() (*Record, error) {
// 检查是否超出索引范围
if c.endIdx >= c.index.Count() {
return nil, io.EOF
@@ -64,31 +67,41 @@ func (c *LogCursor) Next() (*Record, error) {
return nil, fmt.Errorf("get offset from index: %w", err)
}
// 写入保护:检查读取位置是否超过当前写入位置
if c.writer != nil {
dirtyOffset := c.writer.GetDirtyOffset()
// 如果正在写入dirtyOffset >= 0且记录起始位置 >= 写入位置,说明数据还未完全写入,返回 EOF 等待
if dirtyOffset >= 0 && offset >= dirtyOffset {
return nil, io.EOF
}
}
// Seek 到记录位置
if _, err := c.fd.Seek(offset, 0); err != nil {
return nil, fmt.Errorf("seek to offset %d: %w", offset, err)
}
// 读取头部:[4B len][4B CRC][16B UUID] = 24 字节
hdr := c.rbuf[:24]
// 读取头部:[4B len][8B offset][4B CRC][16B UUID] = 32 字节
hdr := c.rbuf[:32]
if _, err := io.ReadFull(c.fd, hdr); err != nil {
return nil, err
}
var rec Record
rec.Len = binary.LittleEndian.Uint32(hdr[0:4])
rec.CRC = binary.LittleEndian.Uint32(hdr[4:8])
// hdr[4:12] 是 offset读取时不需要使用
rec.CRC = binary.LittleEndian.Uint32(hdr[12:16])
// 读取并校验 UUID
copy(rec.UUID[:], hdr[8:24])
copy(rec.UUID[:], hdr[16:32])
if _, err := uuid.FromBytes(rec.UUID[:]); err != nil {
return nil, fmt.Errorf("invalid uuid: %w", err)
return nil, fmt.Errorf("%w: %v", ErrInvalidUUID, err)
}
// 如果数据大于缓冲区,分配新的 buffer
var payload []byte
if int(rec.Len) <= len(c.rbuf)-24 {
payload = c.rbuf[24 : 24+rec.Len]
if int(rec.Len) <= len(c.rbuf)-32 {
payload = c.rbuf[32 : 32+rec.Len]
} else {
payload = make([]byte, rec.Len)
}
@@ -97,7 +110,7 @@ func (c *LogCursor) Next() (*Record, error) {
return nil, err
}
if crc32.ChecksumIEEE(payload) != rec.CRC {
return nil, fmt.Errorf("crc mismatch")
return nil, fmt.Errorf("%w: offset=%d", ErrCRCMismatch, offset)
}
rec.Data = append([]byte(nil), payload...) // 复制出去,复用 buffer
@@ -110,9 +123,9 @@ func (c *LogCursor) Next() (*Record, error) {
// NextRange 读取指定数量的记录(范围游动)
// count: 要读取的记录数量
// 返回:读取到的记录列表,如果到达文件末尾,返回的记录数可能少于 count
func (c *LogCursor) NextRange(count int) ([]*Record, error) {
func (c *ProcessCursor) NextRange(count int) ([]*Record, error) {
if count <= 0 {
return nil, fmt.Errorf("count must be greater than 0")
return nil, NewValidationError("count", "count must be greater than 0", ErrInvalidCount)
}
results := make([]*Record, 0, count)
@@ -133,34 +146,64 @@ func (c *LogCursor) NextRange(count int) ([]*Record, error) {
}
// Commit 提交窗口,将 endIdx 移动到 startIdx表示已处理完这批记录
func (c *LogCursor) Commit() {
func (c *ProcessCursor) Commit() {
c.startIdx = c.endIdx
}
// Rollback 回滚窗口,将 endIdx 回退到 startIdx表示放弃这批记录的处理
func (c *LogCursor) Rollback() error {
func (c *ProcessCursor) Rollback() error {
c.endIdx = c.startIdx
return nil
}
// StartIndex 获取窗口开始索引
func (c *LogCursor) StartIndex() int {
func (c *ProcessCursor) StartIndex() int {
return c.startIdx
}
// EndIndex 获取窗口结束索引
func (c *LogCursor) EndIndex() int {
func (c *ProcessCursor) EndIndex() int {
return c.endIdx
}
// Close 关闭游标并保存位置
func (c *LogCursor) Close() error {
func (c *ProcessCursor) Close() error {
c.savePosition()
return c.fd.Close()
}
// Reset 重置游标,删除位置文件并重新打开日志文件
// 保持 index 和 writer 引用不变
func (c *ProcessCursor) Reset() error {
// 关闭文件
if c.fd != nil {
if err := c.fd.Close(); err != nil {
return err
}
c.fd = nil
}
// 删除位置文件
if err := os.Remove(c.posFile); err != nil && !os.IsNotExist(err) {
return err
}
// 重新打开日志文件
fd, err := os.Open(c.path)
if err != nil {
return err
}
// 重置状态
c.fd = fd
c.startIdx = 0
c.endIdx = 0
return nil
}
// savePosition 保存当前读取位置到文件
func (c *LogCursor) savePosition() error {
func (c *ProcessCursor) savePosition() error {
f, err := os.Create(c.posFile)
if err != nil {
return err
@@ -175,7 +218,7 @@ func (c *LogCursor) savePosition() error {
}
// loadPosition 从文件加载上次的读取位置
func (c *LogCursor) loadPosition() error {
func (c *ProcessCursor) loadPosition() error {
f, err := os.Open(c.posFile)
if err != nil {
if os.IsNotExist(err) {

173
errors.go Normal file
View File

@@ -0,0 +1,173 @@
package seqlog
import (
"errors"
"fmt"
)
// 哨兵错误Sentinel Errors- 可以使用 errors.Is 进行判断
var (
// ErrNilParameter 表示必需的参数为 nil
ErrNilParameter = errors.New("required parameter is nil")
// ErrInvalidCount 表示 count 参数必须大于 0
ErrInvalidCount = errors.New("count must be greater than 0")
// ErrInvalidRange 表示索引或范围参数无效
ErrInvalidRange = errors.New("invalid index or range")
// ErrAlreadyRunning 表示组件已经在运行
ErrAlreadyRunning = errors.New("already running")
// ErrNotRunning 表示组件未运行
ErrNotRunning = errors.New("not running")
// ErrAlreadyRegistered 表示资源已经注册
ErrAlreadyRegistered = errors.New("already registered")
// ErrNotFound 表示资源未找到
ErrNotFound = errors.New("not found")
// ErrCRCMismatch 表示 CRC 校验失败
ErrCRCMismatch = errors.New("crc mismatch")
// ErrInvalidUUID 表示 UUID 格式无效
ErrInvalidUUID = errors.New("invalid uuid")
// ErrInvalidConfig 表示配置无效
ErrInvalidConfig = errors.New("invalid config")
// ErrProcessorResetting 表示处理器正在重置中,操作被阻止
ErrProcessorResetting = errors.New("processor is resetting, operations blocked")
// ErrInvalidState 表示处理器状态无效,不允许执行该操作
ErrInvalidState = errors.New("invalid processor state for this operation")
)
// TopicError 表示与 topic 相关的错误
type TopicError struct {
Topic string // topic 名称
Op string // 操作名称(如 "write", "query", "start"
Err error // 底层错误
}
func (e *TopicError) Error() string {
if e.Err != nil {
return fmt.Sprintf("topic %s: %s: %v", e.Topic, e.Op, e.Err)
}
return fmt.Sprintf("topic %s: %s", e.Topic, e.Op)
}
func (e *TopicError) Unwrap() error {
return e.Err
}
// NewTopicError 创建一个 topic 相关的错误
func NewTopicError(topic, op string, err error) *TopicError {
return &TopicError{
Topic: topic,
Op: op,
Err: err,
}
}
// FileError 表示文件操作相关的错误
type FileError struct {
Path string // 文件路径
Op string // 操作名称(如 "open", "read", "write"
Err error // 底层错误
}
func (e *FileError) Error() string {
if e.Err != nil {
return fmt.Sprintf("file %s: %s: %v", e.Path, e.Op, e.Err)
}
return fmt.Sprintf("file %s: %s", e.Path, e.Op)
}
func (e *FileError) Unwrap() error {
return e.Err
}
// NewFileError 创建一个文件操作相关的错误
func NewFileError(path, op string, err error) *FileError {
return &FileError{
Path: path,
Op: op,
Err: err,
}
}
// IndexError 表示索引相关的错误
type IndexError struct {
Index int // 请求的索引
Max int // 最大有效索引
Err error // 底层错误(通常是 ErrInvalidRange
}
func (e *IndexError) Error() string {
if e.Err != nil {
return fmt.Sprintf("index %d out of range [0, %d): %v", e.Index, e.Max, e.Err)
}
return fmt.Sprintf("index %d out of range [0, %d)", e.Index, e.Max)
}
func (e *IndexError) Unwrap() error {
return e.Err
}
// NewIndexError 创建一个索引越界错误
func NewIndexError(index, max int) *IndexError {
return &IndexError{
Index: index,
Max: max,
Err: ErrInvalidRange,
}
}
// ValidationError 表示参数验证错误
type ValidationError struct {
Field string // 字段名称
Message string // 错误消息
Err error // 底层错误
}
func (e *ValidationError) Error() string {
if e.Err != nil {
return fmt.Sprintf("validation error: %s: %s: %v", e.Field, e.Message, e.Err)
}
return fmt.Sprintf("validation error: %s: %s", e.Field, e.Message)
}
func (e *ValidationError) Unwrap() error {
return e.Err
}
// NewValidationError 创建一个参数验证错误
func NewValidationError(field, message string, err error) *ValidationError {
return &ValidationError{
Field: field,
Message: message,
Err: err,
}
}
// IsTopicNotFound 检查错误是否为 topic 不存在
func IsTopicNotFound(err error) bool {
var topicErr *TopicError
if errors.As(err, &topicErr) {
return errors.Is(topicErr.Err, ErrNotFound)
}
return errors.Is(err, ErrNotFound)
}
// IsIndexOutOfRange 检查错误是否为索引越界
func IsIndexOutOfRange(err error) bool {
var indexErr *IndexError
return errors.As(err, &indexErr)
}
// IsCRCMismatch 检查错误是否为 CRC 校验失败
func IsCRCMismatch(err error) bool {
return errors.Is(err, ErrCRCMismatch)
}

113
errors_test.go Normal file
View File

@@ -0,0 +1,113 @@
package seqlog
import (
"errors"
"testing"
)
func TestErrorTypes(t *testing.T) {
t.Run("TopicError", func(t *testing.T) {
err := NewTopicError("app", "write", ErrNotFound)
if err.Error() != "topic app: write: not found" {
t.Errorf("unexpected error message: %v", err)
}
// 测试 Unwrap
if !errors.Is(err, ErrNotFound) {
t.Error("expected ErrNotFound")
}
// 测试 errors.As
var topicErr *TopicError
if !errors.As(err, &topicErr) {
t.Error("expected TopicError")
}
if topicErr.Topic != "app" {
t.Errorf("expected topic 'app', got '%s'", topicErr.Topic)
}
})
t.Run("FileError", func(t *testing.T) {
err := NewFileError("/path/to/file", "open", errors.New("permission denied"))
if err.Error() != "file /path/to/file: open: permission denied" {
t.Errorf("unexpected error message: %v", err)
}
})
t.Run("IndexError", func(t *testing.T) {
err := NewIndexError(100, 50)
if err.Error() != "index 100 out of range [0, 50): invalid index or range" {
t.Errorf("unexpected error message: %v", err)
}
// 测试 errors.Is
if !errors.Is(err, ErrInvalidRange) {
t.Error("expected ErrInvalidRange")
}
// 测试 IsIndexOutOfRange
if !IsIndexOutOfRange(err) {
t.Error("expected IsIndexOutOfRange to return true")
}
})
t.Run("ValidationError", func(t *testing.T) {
err := NewValidationError("count", "must be greater than 0", ErrInvalidCount)
if err.Error() != "validation error: count: must be greater than 0: count must be greater than 0" {
t.Errorf("unexpected error message: %v", err)
}
// 测试 errors.Is
if !errors.Is(err, ErrInvalidCount) {
t.Error("expected ErrInvalidCount")
}
})
t.Run("IsTopicNotFound", func(t *testing.T) {
err := NewTopicError("app", "get", ErrNotFound)
if !IsTopicNotFound(err) {
t.Error("expected IsTopicNotFound to return true")
}
// 测试其他错误
if IsTopicNotFound(ErrInvalidCount) {
t.Error("expected IsTopicNotFound to return false for ErrInvalidCount")
}
})
t.Run("IsCRCMismatch", func(t *testing.T) {
if !IsCRCMismatch(ErrCRCMismatch) {
t.Error("expected IsCRCMismatch to return true")
}
// 测试其他错误
if IsCRCMismatch(ErrNotFound) {
t.Error("expected IsCRCMismatch to return false for ErrNotFound")
}
})
t.Run("SentinelErrors", func(t *testing.T) {
// 测试所有哨兵错误都不为 nil
sentinelErrors := []error{
ErrNilParameter,
ErrInvalidCount,
ErrInvalidRange,
ErrAlreadyRunning,
ErrNotRunning,
ErrAlreadyRegistered,
ErrNotFound,
ErrCRCMismatch,
ErrInvalidUUID,
ErrInvalidConfig,
}
for _, err := range sentinelErrors {
if err == nil {
t.Error("sentinel error should not be nil")
}
if err.Error() == "" {
t.Error("sentinel error should have a message")
}
}
})
}

View File

@@ -17,6 +17,7 @@ const (
EventProcessorStop // Processor 停止
EventProcessorReset // Processor 重置
EventPositionSaved // 位置保存
EventStateChanged // 状态变更
)
// String 返回事件类型的字符串表示
@@ -38,6 +39,8 @@ func (e EventType) String() string {
return "Processor 重置"
case EventPositionSaved:
return "位置保存"
case EventStateChanged:
return "状态变更"
default:
return "未知事件"
}

View File

@@ -192,10 +192,10 @@ for i := 0; i < 20; i++ {
**1. "no such file or directory" 错误**
确保在创建 Seqlog 之前先创建目录:
确保在创建 LogHub 之前先创建目录:
```go
os.MkdirAll("log_dir", 0755)
seq := seqlog.NewSeqlog("log_dir", logger, nil)
seq := seqlog.NewLogHub("log_dir", logger, nil)
```
**2. 查询时出现 EOF 错误**

View File

@@ -1,93 +0,0 @@
# 运行高并发示例
## 快速开始
```bash
cd example/concurrent
go run main.go
```
## 预计运行时间
**总时间:约 5 分钟**
- 场景 1 (并发写入): ~27 秒
- 场景 2 (并发查询): ~3 秒
- 场景 3 (混合读写): ~14 秒
- 场景 4 (持续压测): 4 分钟
- 场景 5 (统计汇总): ~10 秒
## 后台运行
如果想在后台运行并保存日志:
```bash
cd example/concurrent
go run main.go > output.log 2>&1 &
echo $! > pid.txt
# 查看实时输出
tail -f output.log
# 停止程序
kill $(cat pid.txt)
rm pid.txt
```
## 查看进度
程序在场景 4持续压测阶段会每 10 秒显示一次进度:
```
[进度] 已运行 10 秒 - 写入: 1951 条, 查询: 1920 次
[进度] 已运行 20 秒 - 写入: 3902 条, 查询: 3840 次
...
```
## 性能指标
根据测试结果,您应该会看到:
- **写入吞吐量**: ~220-240 msg/s
- **查询吞吐量**: ~400-1500 query/s取决于数据量
- **并发处理**: 3 个 topic 同时写入和查询
## 故障排查
### 问题:程序卡住不动
如果程序在某个阶段卡住:
1. 检查磁盘空间是否充足
2. 检查是否有其他进程占用文件
3. 尝试清理测试目录:`rm -rf test_concurrent`
### 问题:查询出现 EOF 错误
这是正常现象!当 tailer 正在处理文件时,查询可能会读取到不完整的记录。程序会自动处理这些错误。
### 问题:性能比预期低
可能的原因:
- 磁盘性能较慢(特别是在虚拟机或网络存储上)
- 系统负载较高
- 索引批量同步设置(可以通过修改 `index.go` 中的 `DefaultSyncBatch` 调整)
## 自定义测试
如果想调整测试参数,编辑 `concurrent/main.go`
```go
// 场景 1每个 topic 写入的消息数
messagesPerTopic := 2000
// 场景 2每个 goroutine 的查询次数
queriesPerGoroutine := 200
// 场景 4持续压测时间
stressTestDuration := 4 * time.Minute
```
## 预期输出示例
完整输出请参考 [README.md](README.md)。

View File

@@ -40,7 +40,7 @@ func main() {
}
// 创建 Seqlog 实例(默认处理器)
seq := seqlog.NewSeqlog(testDir, logger, nil)
seq := seqlog.NewLogHub(testDir, logger, nil)
topics := []string{"app", "access", "error"}
for _, topic := range topics {

View File

@@ -37,8 +37,8 @@ func main() {
lastOffset = offset
fmt.Printf("写入: offset=%d, data=%s\n", offset, data)
}
defer writer.Close()
writer.Close()
fmt.Printf("索引文件已创建: %s.idx\n\n", logPath)
// ===== 示例 2使用索引进行快速查询 =====
@@ -52,7 +52,7 @@ func main() {
defer index2.Close()
// 创建查询器(使用外部索引)
query, err := seqlog.NewRecordQuery(logPath, index2)
query, err := seqlog.NewRecordQuery(logPath, index2, writer)
if err != nil {
log.Fatal(err)
}
@@ -69,24 +69,26 @@ func main() {
startIndex := 5
fmt.Printf("从第 %d 条记录开始查询\n", startIndex)
// 向查询(查询更早的记录)
backward, err := query.QueryNewest(startIndex-1, 3)
// 向索引递减方向查询(查询更早的记录)
// QueryOldest(5, 3) 查询索引 2, 3, 4不包含 5返回按索引递增排序
backward, err := query.QueryOldest(startIndex, 3)
if err != nil {
log.Fatal(err)
}
fmt.Printf("向查询 3 条记录:\n")
for i, rec := range backward {
fmt.Printf(" [%d] 数据=%s\n", i, string(rec.Data))
fmt.Printf("向索引递减方向查询 3 条记录:\n")
for _, rec := range backward {
fmt.Printf(" [索引 %d] 数据=%s\n", rec.Index, string(rec.Record.Data))
}
// 向查询(查询更新的记录)
forward, err := query.QueryOldest(startIndex, 3)
// 向索引递增方向查询(查询更新的记录)
// QueryNewest(5, 3) 查询索引 6, 7, 8不包含 5返回按索引递增排序
forward, err := query.QueryNewest(startIndex, 3)
if err != nil {
log.Fatal(err)
}
fmt.Printf("向查询 3 条记录:\n")
for i, rec := range forward {
fmt.Printf(" [%d] 数据=%s\n", i, string(rec.Data))
fmt.Printf("向索引递增方向查询 3 条记录:\n")
for _, rec := range forward {
fmt.Printf(" [索引 %d] 数据=%s\n", rec.Index, string(rec.Record.Data))
}
fmt.Println()

View File

@@ -40,58 +40,48 @@ func main() {
count := tp.GetRecordCount()
fmt.Printf(" 总共 %d 条记录\n\n", count)
// ===== 3. 获取索引 =====
fmt.Println("3. 使用索引")
index := tp.Index()
fmt.Printf(" 索引记录数: %d\n", index.Count())
fmt.Printf(" 最后偏移: %d\n\n", index.LastOffset())
// ===== 3. 获取记录数 =====
fmt.Println("3. 查看记录统计")
totalCount := tp.GetRecordCount()
fmt.Printf(" 记录数: %d\n\n", totalCount)
// ===== 4. 使用查询器查询 =====
fmt.Println("4. 查询记录:")
// 查询最老的 3 条记录(从索引 0 开始)
oldest, err := tp.QueryOldest(0, 3)
oldest, err := tp.QueryOldest(3, 3)
if err != nil {
log.Fatal(err)
}
fmt.Println(" 查询最老的 3 条:")
for i, rws := range oldest {
fmt.Printf(" [%d] 状态=%s, 数据=%s\n", i, rws.Status, string(rws.Record.Data))
fmt.Printf(" [%d] 索引=%d, 状态=%s, 数据=%s\n", i, rws.Index, rws.Status, string(rws.Record.Data))
}
// 查询最新的 2 条记录(从最后一条开始)
totalCount := tp.GetRecordCount()
newest, err := tp.QueryNewest(totalCount-1, 2)
newest, err := tp.QueryNewest(totalCount-3, 2)
if err != nil {
log.Fatal(err)
}
fmt.Println(" 查询最新的 2 条:")
for i, rws := range newest {
fmt.Printf(" [%d] 状态=%s, 数据=%s\n", i, rws.Status, string(rws.Record.Data))
fmt.Printf(" [%d] 索引=%d, 状态=%s, 数据=%s\n", i, rws.Index, rws.Status, string(rws.Record.Data))
}
fmt.Println()
// ===== 5. 使用游标读取 =====
fmt.Println("5. 使用游标读取")
cursor, err := tp.Cursor()
// ===== 5. 从处理窗口查询 =====
fmt.Println("5. 从处理窗口查询")
// 从处理窗口开始位置查询 3 条记录
processing, err := tp.QueryFromProcessing(3)
if err != nil {
log.Fatal(err)
}
defer cursor.Close()
// 读取 3 条记录
records, err := cursor.NextRange(3)
if err != nil {
log.Fatal(err)
fmt.Printf(" 从处理窗口查询到 %d 条记录:\n", len(processing))
for i, rec := range processing {
fmt.Printf(" [%d] 索引=%d, 状态=%s, 数据=%s\n", i, rec.Index, rec.Status, string(rec.Record.Data))
}
fmt.Printf(" 读取了 %d 条记录:\n", len(records))
for i, rec := range records {
fmt.Printf(" [%d] %s\n", i, string(rec.Data))
}
// 提交游标位置
cursor.Commit()
fmt.Printf(" 游标位置: start=%d, end=%d\n\n", cursor.StartIndex(), cursor.EndIndex())
fmt.Println()
// ===== 6. 继续写入 =====
fmt.Println("6. 继续写入:")

View File

@@ -1,65 +0,0 @@
# Seqlog Web 演示
一个简单的 Web 应用,展示 Seqlog 的实际使用场景。
## 功能
### 后端模拟业务
- 每 2 秒自动生成业务日志
- 随机生成不同 topicapp、api、database、cache
- 随机生成不同操作(查询、插入、更新、删除、备份、恢复、同步等)
- **随机日志大小**2KB ~ 10MB
- 80% 小日志2KB - 100KB
- 15% 中日志100KB - 1MB
- 5% 大日志1MB - 10MB
### Web 查询界面
- 查看所有 topics
- 查看每个 topic 的统计信息(显示实际字节数)
- 查询日志(支持向前/向后翻页)
- 实时自动刷新
- 日志状态标注(已处理/处理中/待处理)
## 快速启动
```bash
cd example/webapp
go run main.go
```
访问: http://localhost:8080
## 使用说明
1. **选择 Topic**: 点击左侧的 topic 列表
2. **查看统计**: 左侧会显示该 topic 的统计信息(包括总字节数)
3. **查看日志**: 右侧显示日志内容,带状态标注
4. **刷新**: 点击"刷新日志"按钮或等待自动刷新
5. **翻页**: 使用"向前翻页"和"向后翻页"按钮
6. **自定义范围**: 修改显示范围的数字,控制查询条数
## 界面说明
- **绿色边框**: 已处理的日志
- **黄色边框**: 正在处理的日志
- **灰色边框**: 待处理的日志
## 性能测试
由于日志大小范围很大2KB ~ 10MB可以观察到
- 小日志处理速度很快
- 大日志会占用更多存储空间
- 统计信息会显示真实的字节数增长
## API 接口
- `GET /api/topics` - 获取所有 topics
- `GET /api/stats?topic=<name>` - 获取统计信息
- `GET /api/query?topic=<name>&backward=10&forward=10` - 查询日志
- `POST /api/write` - 手动写入日志
## 技术栈
- 后端: Go + Seqlog
- 前端: 原生 HTML/CSS/JavaScript
- 无需额外依赖

View File

@@ -1,658 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"math/rand"
"net/http"
"os"
"strconv"
"time"
"code.tczkiot.com/seqlog"
)
var (
seq *seqlog.Seqlog
logger *slog.Logger
)
func main() {
// 初始化
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// 创建 Seqlog
seq = seqlog.NewSeqlog("logs", logger, func(topic string, rec *seqlog.Record) error {
// 简单的日志处理:只打印摘要信息
dataPreview := string(rec.Data)
if len(dataPreview) > 100 {
dataPreview = dataPreview[:100] + "..."
}
logger.Info("处理日志", "topic", topic, "size", len(rec.Data), "preview", dataPreview)
return nil
})
if err := seq.Start(); err != nil {
logger.Error("启动失败", "error", err)
os.Exit(1)
}
defer seq.Stop()
logger.Info("Seqlog 已启动")
// 启动后台业务模拟
go simulateBusiness()
// 启动 Web 服务器
http.HandleFunc("/", handleIndex)
http.HandleFunc("/api/topics", handleTopics)
http.HandleFunc("/api/stats", handleStats)
http.HandleFunc("/api/query", handleQuery)
http.HandleFunc("/api/write", handleWrite)
addr := ":8080"
logger.Info("Web 服务器启动", "地址", "http://localhost"+addr)
if err := http.ListenAndServe(addr, nil); err != nil {
logger.Error("服务器错误", "error", err)
}
}
// 模拟业务写日志
func simulateBusiness() {
topics := []string{"app", "api", "database", "cache"}
actions := []string{"查询", "插入", "更新", "删除", "连接", "断开", "备份", "恢复", "同步"}
status := []string{"成功", "失败", "超时", "重试"}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
// 随机选择 topic 和内容
topic := topics[rand.Intn(len(topics))]
action := actions[rand.Intn(len(actions))]
st := status[rand.Intn(len(status))]
// 随机生成日志大小2KB 到 10MB
// 80% 概率生成小日志2KB-100KB
// 15% 概率生成中日志100KB-1MB
// 5% 概率生成大日志1MB-10MB
var logSize int
prob := rand.Intn(100)
if prob < 80 {
// 2KB - 100KB
logSize = 2*1024 + rand.Intn(98*1024)
} else if prob < 95 {
// 100KB - 1MB
logSize = 100*1024 + rand.Intn(924*1024)
} else {
// 1MB - 10MB
logSize = 1024*1024 + rand.Intn(9*1024*1024)
}
// 生成日志内容
header := fmt.Sprintf("[%s] %s %s - 用时: %dms | 数据大小: %s | ",
time.Now().Format("15:04:05"),
action,
st,
rand.Intn(1000),
formatBytes(int64(logSize)))
// 填充随机数据到指定大小
data := make([]byte, logSize)
copy(data, []byte(header))
// 填充可读的模拟数据
fillOffset := len(header)
patterns := []string{
"user_id=%d, session=%x, ip=%d.%d.%d.%d, ",
"query_time=%dms, rows=%d, cached=%v, ",
"error_code=%d, retry_count=%d, ",
"request_id=%x, trace_id=%x, ",
}
for fillOffset < logSize-100 {
pattern := patterns[rand.Intn(len(patterns))]
var chunk string
switch pattern {
case patterns[0]:
chunk = fmt.Sprintf(pattern, rand.Intn(10000), rand.Intn(0xFFFFFF),
rand.Intn(256), rand.Intn(256), rand.Intn(256), rand.Intn(256))
case patterns[1]:
chunk = fmt.Sprintf(pattern, rand.Intn(1000), rand.Intn(10000), rand.Intn(2) == 1)
case patterns[2]:
chunk = fmt.Sprintf(pattern, rand.Intn(500), rand.Intn(5))
case patterns[3]:
chunk = fmt.Sprintf(pattern, rand.Intn(0xFFFFFFFF), rand.Intn(0xFFFFFFFF))
}
remaining := logSize - fillOffset
if len(chunk) > remaining {
chunk = chunk[:remaining]
}
copy(data[fillOffset:], []byte(chunk))
fillOffset += len(chunk)
}
// 写入日志
if _, err := seq.Write(topic, data); err != nil {
logger.Error("写入日志失败", "error", err, "size", logSize)
} else {
logger.Info("写入日志", "topic", topic, "size", formatBytes(int64(logSize)))
}
}
}
func formatBytes(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
if bytes < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
}
return fmt.Sprintf("%.2f MB", float64(bytes)/1024/1024)
}
// 首页
func handleIndex(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Seqlog 日志查询</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
margin: 0;
color: #333;
}
.subtitle {
color: #666;
margin-top: 5px;
}
.container {
display: grid;
grid-template-columns: 250px 1fr;
gap: 20px;
}
.sidebar {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.main {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.topic-list {
list-style: none;
padding: 0;
margin: 0;
}
.topic-item {
padding: 10px;
margin-bottom: 5px;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.topic-item:hover {
background: #f0f0f0;
}
.topic-item.active {
background: #007bff;
color: white;
}
.stats {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
}
.stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.controls {
margin-bottom: 20px;
}
.btn {
padding: 8px 16px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.log-container {
height: 500px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
background: #f8f9fa;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.log-entry {
padding: 6px 10px;
margin-bottom: 4px;
background: white;
border-left: 3px solid #007bff;
border-radius: 2px;
word-wrap: break-word;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.log-entry.processed {
border-left-color: #28a745;
opacity: 0.8;
}
.log-entry.processing {
border-left-color: #ffc107;
background: #fff9e6;
}
.log-entry.pending {
border-left-color: #6c757d;
opacity: 0.6;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
margin-right: 8px;
}
.status-processed {
background: #d4edda;
color: #155724;
}
.status-processing {
background: #fff3cd;
color: #856404;
}
.status-pending {
background: #e2e3e5;
color: #383d41;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
</head>
<body>
<div class="header">
<h1>Seqlog 日志查询系统</h1>
<div class="subtitle">实时查看和管理应用日志</div>
</div>
<div class="container">
<div class="sidebar">
<h3>Topics</h3>
<ul class="topic-list" id="topicList"></ul>
<div class="stats" id="stats">
<h4>统计信息</h4>
<div id="statsContent">选择一个 topic 查看统计</div>
</div>
</div>
<div class="main">
<div class="controls">
<button class="btn btn-primary" onclick="loadLogs()">刷新日志</button>
<button class="btn btn-secondary" onclick="queryBackward()">向前翻页</button>
<button class="btn btn-secondary" onclick="queryForward()">向后翻页</button>
<span style="margin-left: 20px;">显示范围: 前 <input type="number" id="backwardCount" value="10" style="width: 60px;"> 条, 后 <input type="number" id="forwardCount" value="10" style="width: 60px;"> 条</span>
</div>
<div class="log-container" id="logContainer">
<div class="loading">选择一个 topic 开始查看日志</div>
</div>
</div>
</div>
<script>
let currentTopic = null;
let currentCenterIndex = null; // 追踪当前中心索引位置
// 加载 topics
async function loadTopics() {
const response = await fetch('/api/topics');
const topics = await response.json();
const list = document.getElementById('topicList');
list.innerHTML = topics.map(topic =>
'<li class="topic-item" onclick="selectTopic(\'' + topic + '\')">' + topic + '</li>'
).join('');
}
// 选择 topic
function selectTopic(topic) {
currentTopic = topic;
currentCenterIndex = null; // 切换 topic 时重置索引
// 更新选中状态
document.querySelectorAll('.topic-item').forEach(item => {
item.classList.remove('active');
if (item.textContent === topic) {
item.classList.add('active');
}
});
// 清空容器并重新加载
document.getElementById('logContainer').innerHTML = '';
loadStats(topic);
loadLogs();
}
// 加载统计
async function loadStats(topic) {
const response = await fetch('/api/stats?topic=' + topic);
const stats = await response.json();
const content = document.getElementById('statsContent');
content.innerHTML =
'<div class="stat-item"><span>写入:</span><span>' + stats.write_count + ' 条</span></div>' +
'<div class="stat-item"><span>处理:</span><span>' + stats.processed_count + ' 条</span></div>' +
'<div class="stat-item"><span>错误:</span><span>' + stats.error_count + ' 次</span></div>' +
'<div class="stat-item"><span>大小:</span><span>' + formatBytes(stats.write_bytes) + '</span></div>';
}
// 加载日志
async function loadLogs(index) {
if (!currentTopic) return;
const backward = document.getElementById('backwardCount').value;
const forward = document.getElementById('forwardCount').value;
// 构建查询 URL
let url = '/api/query?topic=' + currentTopic +
'&backward=' + backward + '&forward=' + forward;
// 如果指定了索引,添加到查询参数
if (index !== undefined && index !== null) {
url += '&index=' + index;
}
const response = await fetch(url);
const data = await response.json();
// 更新当前中心索引
currentCenterIndex = data.centerIndex;
const container = document.getElementById('logContainer');
if (data.records.length === 0) {
container.innerHTML = '<div class="loading">暂无日志</div>';
return;
}
// 清空并重新渲染所有记录
const html = data.records.map(r => {
// 解析状态
let statusClass = 'pending';
let statusText = '待处理';
let badgeClass = 'status-pending';
if (r.status === 'StatusProcessed' || r.status === 'processed') {
statusClass = 'processed';
statusText = '已处理';
badgeClass = 'status-processed';
} else if (r.status === 'StatusProcessing' || r.status === 'processing') {
statusClass = 'processing';
statusText = '处理中';
badgeClass = 'status-processing';
}
return '<div class="log-entry ' + statusClass + '" data-index="' + r.index + '">' +
'<span class="status-badge ' + badgeClass + '">[#' + r.index + '] ' + statusText + '</span>' +
r.data +
'</div>';
}).join('');
container.innerHTML = html;
}
function queryBackward() {
if (currentCenterIndex === null || currentCenterIndex === undefined) {
loadLogs();
return;
}
const backward = parseInt(document.getElementById('backwardCount').value);
const forward = parseInt(document.getElementById('forwardCount').value);
// 向前翻页中心索引向前移动backward + forward + 1个位置
const newIndex = currentCenterIndex - (backward + forward + 1);
loadLogs(newIndex >= 0 ? newIndex : 0);
}
function queryForward() {
if (currentCenterIndex === null || currentCenterIndex === undefined) {
loadLogs();
return;
}
const backward = parseInt(document.getElementById('backwardCount').value);
const forward = parseInt(document.getElementById('forwardCount').value);
// 向后翻页中心索引向后移动backward + forward + 1个位置
const newIndex = currentCenterIndex + (backward + forward + 1);
loadLogs(newIndex);
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
// 初始化
loadTopics();
// 只自动刷新统计信息,不刷新日志
setInterval(() => {
if (currentTopic) {
loadStats(currentTopic);
}
}, 3000); // 每 3 秒刷新统计
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, html)
}
// API: 获取所有 topics
func handleTopics(w http.ResponseWriter, r *http.Request) {
topics := seq.GetTopics()
json.NewEncoder(w).Encode(topics)
}
// API: 获取统计信息
func handleStats(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "缺少 topic 参数", http.StatusBadRequest)
return
}
stats, err := seq.GetTopicStats(topic)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(stats)
}
// API: 查询日志
func handleQuery(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "缺少 topic 参数", http.StatusBadRequest)
return
}
// 获取查询参数
indexParam := r.URL.Query().Get("index")
backward, _ := strconv.Atoi(r.URL.Query().Get("backward"))
forward, _ := strconv.Atoi(r.URL.Query().Get("forward"))
if backward == 0 {
backward = 10
}
if forward == 0 {
forward = 10
}
// 获取 processor
processor, err := seq.GetProcessor(topic)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// 获取记录总数
totalCount := processor.GetRecordCount()
// 确定查询中心索引
var centerIndex int
if indexParam == "" {
// 如果没有指定索引,使用当前处理位置
centerIndex = seq.GetProcessingIndex(topic)
} else {
centerIndex, _ = strconv.Atoi(indexParam)
// 限制索引范围
if centerIndex < 0 {
centerIndex = 0
}
if centerIndex >= totalCount {
centerIndex = totalCount - 1
}
}
// 获取当前处理索引和读取索引(用于状态判断)
startIdx := seq.GetProcessingIndex(topic)
endIdx := seq.GetReadIndex(topic)
// 合并查询结果:向后 + 当前 + 向前
var results []*seqlog.RecordWithStatus
// 向后查询(查询更早的记录)
if backward > 0 && centerIndex > 0 {
backResults, err := processor.QueryNewest(centerIndex-1, backward)
if err == nil {
results = append(results, backResults...)
}
}
// 当前位置的记录
if centerIndex >= 0 && centerIndex < totalCount {
currentResults, err := processor.QueryOldest(centerIndex, 1)
if err == nil {
results = append(results, currentResults...)
}
}
// 向前查询(查询更新的记录)
if forward > 0 && centerIndex+1 < totalCount {
forwardResults, err := processor.QueryOldest(centerIndex+1, forward)
if err == nil {
results = append(results, forwardResults...)
}
}
type Record struct {
Index int `json:"index"`
Status string `json:"status"`
Data string `json:"data"`
}
// 计算每条记录的实际索引位置
startRecordIndex := centerIndex - backward
if startRecordIndex < 0 {
startRecordIndex = 0
}
records := make([]Record, len(results))
for i, r := range results {
records[i] = Record{
Index: startRecordIndex + i,
Status: r.Status.String(),
Data: string(r.Record.Data),
}
}
json.NewEncoder(w).Encode(map[string]interface{}{
"records": records,
"total": len(records),
"centerIndex": centerIndex,
"totalCount": totalCount,
"processingIndex": startIdx,
"readIndex": endIdx,
})
}
// API: 手动写入日志
func handleWrite(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "只支持 POST", http.StatusMethodNotAllowed)
return
}
var req struct {
Topic string `json:"topic"`
Data string `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
offset, err := seq.Write(req.Topic, []byte(req.Data))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"offset": offset,
})
}

140
example/webui/README.md Normal file
View File

@@ -0,0 +1,140 @@
# Seqlog Web UI 示例
这个示例展示了如何使用 seqlog 的内置 Web UI 功能。
## 特性
- **零编译前端**:使用 Vue 3 和 Tailwind CSS CDN无需前端构建步骤
- **实时日志查看**:查看多个 topic 的日志记录
- **统计信息**:实时显示每个 topic 的统计数据
- **日志查询**:支持查询最早、最新的日志记录
- **状态追踪**:显示日志的处理状态(待处理、处理中、已处理)
- **灵活集成**:可以集成到现有的 HTTP 服务器或其他框架
## 运行示例
```bash
cd example/webui
go run main.go
```
然后在浏览器中访问 http://localhost:8080
## API 端点
Web UI 提供以下 API 端点:
- `GET /api/topics` - 获取所有 topic 列表
- `GET /api/logs?topic=xxx&count=N` - 查询最新的 N 条日志(从最后一条向前查询)
- `GET /api/logs/first?topic=xxx&count=N` - 查询从第一条开始的 N 条日志(从索引 0 向后查询)
- `GET /api/logs/last?topic=xxx&count=N` - 查询最后的 N 条日志(从最后一条向前查询)
- `GET /api/stats?topic=xxx` - 获取指定 topic 的统计信息
## 使用方式
### 方式 1独立 Web UI 服务器
最简单的方式,直接启动一个独立的 Web UI 服务器:
```go
import "code.tczkiot.com/seqlog"
// 创建 LogHub
hub := seqlog.NewLogHub("./logs", logger, handler)
// 启动服务
hub.Start()
// 启动 Web UI会阻塞
hub.ServeUI(":8080")
```
如果需要在后台运行 Web UI
```go
go func() {
if err := hub.ServeUI(":8080"); err != nil {
logger.Error("Web UI 错误", "error", err)
}
}()
```
### 方式 2集成到现有 HTTP 服务器
如果你已经有一个 HTTP 服务器,可以将 Web UI 集成到现有的 ServeMux
```go
import (
"net/http"
"code.tczkiot.com/seqlog"
)
// 创建 LogHub
hub := seqlog.NewLogHub("./logs", logger, handler)
hub.Start()
// 创建自己的 ServeMux
mux := http.NewServeMux()
// 注册业务端点
mux.HandleFunc("/api/users", handleUsers)
mux.HandleFunc("/health", handleHealth)
// 注册 seqlog Web UI 到根路径
hub.RegisterWebUIRoutes(mux)
// 启动服务器
http.ListenAndServe(":8080", mux)
```
### 方式 3集成到子路径
如果你想将 Web UI 放在子路径下(比如 `/logs/`
```go
// 创建主 ServeMux
mux := http.NewServeMux()
// 注册业务端点
mux.HandleFunc("/health", handleHealth)
// 创建 Web UI 的 ServeMux
logsMux := http.NewServeMux()
hub.RegisterWebUIRoutes(logsMux)
// 挂载到 /logs/ 路径
mux.Handle("/logs/", http.StripPrefix("/logs", logsMux))
// 启动服务器
http.ListenAndServe(":8080", mux)
```
访问 http://localhost:8080/logs/ 查看 Web UI。
完整的集成示例请参考 [example/webui_integration](../webui_integration/main.go)。
### 方式 4集成到其他 Web 框架
对于 gin、echo 等框架,可以通过适配器集成:
```go
// Gin 框架示例
import (
"github.com/gin-gonic/gin"
"net/http"
)
r := gin.Default()
// 业务路由
r.GET("/api/users", handleUsers)
// 创建 seqlog Web UI 的 ServeMux
logsMux := http.NewServeMux()
hub.RegisterWebUIRoutes(logsMux)
// 使用 gin.WrapH 包装 http.Handler
r.Any("/logs/*path", gin.WrapH(http.StripPrefix("/logs", logsMux)))
r.Run(":8080")
```

114
example/webui/main.go Normal file
View File

@@ -0,0 +1,114 @@
package main
import (
"fmt"
"log/slog"
"math/rand"
"os"
"strings"
"sync"
"time"
"unicode/utf8"
"code.tczkiot.com/seqlog"
)
func main() {
// 创建日志目录
baseDir := "./logs"
if err := os.MkdirAll(baseDir, 0755); err != nil {
panic(err)
}
// 创建 LogHub
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// 自定义处理器:打印处理的记录
handler := func(topic string, record *seqlog.Record) error {
previewSize := min(int(record.Len), 100)
validPreviewSize := previewSize
if previewSize > 0 && previewSize < int(record.Len) {
// 只有在截断的情况下才需要检查
// 从后往前最多检查 3 个字节,找到最后一个完整的 UTF-8 字符边界
for i := 0; i < 3 && validPreviewSize > 0; i++ {
if utf8.Valid(record.Data[:validPreviewSize]) {
break
}
validPreviewSize--
}
}
fmt.Printf("[%s] 处理记录: %s\n", topic, string(record.Data[:validPreviewSize]))
return nil
}
hub := seqlog.NewLogHub(baseDir, logger, handler)
// 启动 LogHub会自动发现和启动所有 topic
if err := hub.Start(); err != nil {
panic(err)
}
defer hub.Stop()
// topic 列表(会在第一次写入时自动创建)
topics := []string{"app", "system", "access"}
// 在后台启动 Web UI 服务器
go func() {
fmt.Println("启动 Web UI 服务器: http://localhost:8080")
if err := hub.ServeUI(":8080"); err != nil {
fmt.Printf("Web UI 服务器错误: %v\n", err)
}
}()
// 生成随机大小的数据2KB 到 10MB
generateRandomData := func(minSize, maxSize int) []byte {
size := minSize + rand.Intn(maxSize-minSize)
// 使用重复字符填充,模拟实际日志内容
return []byte(strings.Repeat("X", size))
}
// 启动多个并发写入器(提高并发数)
var wg sync.WaitGroup
concurrentWriters := 10 // 10 个并发写入器
for i := range concurrentWriters {
wg.Add(1)
go func(writerID int) {
defer wg.Done()
count := 0
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
count++
// 随机选择 topic
topic := topics[rand.Intn(len(topics))]
// 生成随机大小的数据2KB 到 2MB
minSize := 2 * 1024 // 2KB
maxSize := 2 * 1024 * 1024 // 2MB
randomData := generateRandomData(minSize, maxSize)
// 组合消息头和随机数据
message := fmt.Sprintf("[Writer-%d] 日志 #%d - %s - 大小: %d bytes\n",
writerID, count, time.Now().Format(time.RFC3339), len(randomData))
fullData := append([]byte(message), randomData...)
if _, err := hub.Write(topic, fullData); err != nil {
fmt.Printf("写入失败: %v\n", err)
}
}
}(i)
}
// 等待用户中断
fmt.Println("\n==== Seqlog Web UI 示例 ====")
fmt.Println("访问 http://localhost:8080 查看 Web UI")
fmt.Println("按 Ctrl+C 退出")
// 阻塞主线程
select {}
}

View File

@@ -0,0 +1,91 @@
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
"time"
"code.tczkiot.com/seqlog"
)
func main() {
// 创建日志目录
baseDir := "./logs"
if err := os.MkdirAll(baseDir, 0755); err != nil {
panic(err)
}
// 创建 LogHub
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
handler := func(topic string, record *seqlog.Record) error {
fmt.Printf("[%s] 处理记录: %s\n", topic, string(record.Data))
return nil
}
hub := seqlog.NewLogHub(baseDir, logger, handler)
// 启动 LogHub
if err := hub.Start(); err != nil {
panic(err)
}
defer hub.Stop()
// 创建自己的 ServeMux
mux := http.NewServeMux()
// 注册自己的业务端点
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","service":"my-app"}`)
})
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "# HELP my_app_requests_total Total requests\n")
fmt.Fprintf(w, "my_app_requests_total 12345\n")
})
// 在 /logs 路径下集成 seqlog Web UI
// 方法 1使用子路径需要创建一个包装 ServeMux
logsMux := http.NewServeMux()
if err := hub.RegisterWebUIRoutes(logsMux); err != nil {
panic(err)
}
mux.Handle("/logs/", http.StripPrefix("/logs", logsMux))
// 启动模拟写日志
go func() {
topics := []string{"app", "system", "access"}
count := 0
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
count++
topic := topics[count%len(topics)]
message := fmt.Sprintf("日志消息 %d - %s", count, time.Now().Format(time.RFC3339))
if _, err := hub.Write(topic, []byte(message)); err != nil {
fmt.Printf("写入失败: %v\n", err)
}
}
}()
// 启动服务器
fmt.Println("\n==== Seqlog 集成示例 ====")
fmt.Println("业务端点:")
fmt.Println(" - http://localhost:8080/health")
fmt.Println(" - http://localhost:8080/metrics")
fmt.Println("Web UI:")
fmt.Println(" - http://localhost:8080/logs/")
fmt.Println("按 Ctrl+C 退出")
if err := http.ListenAndServe(":8080", mux); err != nil {
fmt.Printf("服务器错误: %v\n", err)
}
}

Binary file not shown.

View File

@@ -41,6 +41,7 @@ type RecordIndex struct {
syncBatch int // 同步批次大小
lastSync time.Time // 上次同步时间
dirtyCount int // 未同步的记录数
entryBuf [IndexEntrySize]byte // 可重用的写入缓冲区
mu sync.Mutex // 保护并发访问
}
@@ -156,10 +157,10 @@ func (ri *RecordIndex) Append(offset int64) error {
defer ri.mu.Unlock()
// 追加到索引文件(先写文件,后更新内存)
entryBuf := make([]byte, IndexEntrySize)
binary.LittleEndian.PutUint64(entryBuf, uint64(offset))
// 使用可重用的 buffer 减少内存分配
binary.LittleEndian.PutUint64(ri.entryBuf[:], uint64(offset))
if _, err := ri.indexFile.Write(entryBuf); err != nil {
if _, err := ri.indexFile.Write(ri.entryBuf[:]); err != nil {
return fmt.Errorf("append index entry: %w", err)
}
@@ -182,7 +183,7 @@ func (ri *RecordIndex) Append(offset int64) error {
// GetOffset 根据索引位置获取记录偏移
func (ri *RecordIndex) GetOffset(index int) (int64, error) {
if index < 0 || index >= len(ri.offsets) {
return 0, fmt.Errorf("index out of range: %d (total: %d)", index, len(ri.offsets))
return 0, NewIndexError(index, len(ri.offsets))
}
return ri.offsets[index], nil
}
@@ -248,6 +249,44 @@ func (ri *RecordIndex) Close() error {
return nil
}
// Reset 重置索引,清空所有数据并重新创建索引文件
func (ri *RecordIndex) Reset() error {
ri.mu.Lock()
defer ri.mu.Unlock()
// 关闭索引文件
if ri.indexFile != nil {
if err := ri.indexFile.Close(); err != nil {
return err
}
ri.indexFile = nil
}
// 删除索引文件
if err := os.Remove(ri.indexPath); err != nil && !os.IsNotExist(err) {
return err
}
// 清空内存中的索引数据
ri.offsets = make([]int64, 0, 1024)
ri.dirtyCount = 0
ri.lastSync = time.Now()
// 保存空索引(创建文件并写入头部)
if err := ri.save(); err != nil {
return err
}
// 重新打开索引文件用于追加
f, err := os.OpenFile(ri.indexPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("reopen index file: %w", err)
}
ri.indexFile = f
return nil
}
// Sync 同步索引文件到磁盘(立即同步,不考虑批量策略)
func (ri *RecordIndex) Sync() error {
return ri.Flush()

View File

@@ -162,10 +162,10 @@ func TestQueryWithIndex(t *testing.T) {
t.Fatalf("写入失败: %v", err)
}
}
writer.Close()
defer writer.Close()
// 2. 创建查询器(使用共享索引)
query, err := NewRecordQuery(logPath, index)
query, err := NewRecordQuery(logPath, index, writer)
if err != nil {
t.Fatalf("创建查询器失败: %v", err)
}

View File

@@ -8,14 +8,14 @@ import (
"sync"
)
// Seqlog 日志管理器,统一管理多个 topic 的日志分发
// LogHub 日志中枢,统一管理多个 topic 的日志分发
//
// 自动恢复机制:
// - Start() 时自动扫描 baseDir 中所有 .log 文件
// - 为每个发现的日志文件创建 processor
// - 使用 .pos 文件保存的游标位置恢复处理进度
// - 只处理上次中断后新增的日志,避免重复处理
type Seqlog struct {
type LogHub struct {
baseDir string
processors map[string]*TopicProcessor
defaultHandler TopicRecordHandler
@@ -27,13 +27,13 @@ type Seqlog struct {
running bool
}
// NewSeqlog 创建一个新的日志管理器
// NewLogHub 创建一个新的日志中枢
// logger: 内部日志记录器,如果不需要可以传 slog.Default()
func NewSeqlog(baseDir string, logger *slog.Logger, defaultHandler TopicRecordHandler) *Seqlog {
func NewLogHub(baseDir string, logger *slog.Logger, defaultHandler TopicRecordHandler) *LogHub {
if logger == nil {
logger = slog.Default()
}
return &Seqlog{
return &LogHub{
baseDir: baseDir,
processors: make(map[string]*TopicProcessor),
defaultHandler: defaultHandler,
@@ -48,7 +48,7 @@ func NewSeqlog(baseDir string, logger *slog.Logger, defaultHandler TopicRecordHa
}
// SetDefaultTailConfig 设置默认的 tail 配置
func (s *Seqlog) SetDefaultTailConfig(config *TailConfig) {
func (s *LogHub) SetDefaultTailConfig(config *TailConfig) {
s.mu.Lock()
defer s.mu.Unlock()
if config != nil {
@@ -57,13 +57,13 @@ func (s *Seqlog) SetDefaultTailConfig(config *TailConfig) {
}
// RegisterHandler 为指定 topic 注册 handler
func (s *Seqlog) RegisterHandler(topic string, handler RecordHandler) error {
func (s *LogHub) RegisterHandler(topic string, handler RecordHandler) error {
return s.RegisterHandlerWithConfig(topic, &TopicConfig{Handler: handler})
}
// RegisterHandlerWithConfig 为指定 topic 注册 handler 和配置
// 注意handler 为必填参数,如果 topic 已存在则返回错误
func (s *Seqlog) RegisterHandlerWithConfig(topic string, config *TopicConfig) error {
func (s *LogHub) RegisterHandlerWithConfig(topic string, config *TopicConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -86,7 +86,7 @@ func (s *Seqlog) RegisterHandlerWithConfig(topic string, config *TopicConfig) er
})
} else {
// Processor 已存在handler 不可更新
return fmt.Errorf("handler already registered for topic %s", topic)
return NewTopicError(topic, "register", ErrAlreadyRegistered)
}
s.logger.Info("handler registered", "topic", topic)
@@ -94,7 +94,7 @@ func (s *Seqlog) RegisterHandlerWithConfig(topic string, config *TopicConfig) er
}
// Write 写入日志到指定 topic
func (s *Seqlog) Write(topic string, data []byte) (int64, error) {
func (s *LogHub) Write(topic string, data []byte) (int64, error) {
processor, err := s.getOrCreateProcessor(topic)
if err != nil {
s.logger.Error("failed to get processor", "topic", topic, "error", err)
@@ -109,13 +109,13 @@ func (s *Seqlog) Write(topic string, data []byte) (int64, error) {
return offset, nil
}
// Start 启动 Seqlog 和所有已注册的 processor
func (s *Seqlog) Start() error {
// Start 启动 LogHub 和所有已注册的 processor
func (s *LogHub) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return fmt.Errorf("seqlog is already running")
return ErrAlreadyRunning
}
s.logger.Info("starting seqlog", "baseDir", s.baseDir, "processors", len(s.processors))
@@ -143,7 +143,7 @@ func (s *Seqlog) Start() error {
// discoverExistingTopics 自动发现 baseDir 中已存在的日志文件并创建对应的 processor
// 注意:此方法需要在持有锁的情况下调用
func (s *Seqlog) discoverExistingTopics() error {
func (s *LogHub) discoverExistingTopics() error {
// 确保目录存在
if err := os.MkdirAll(s.baseDir, 0755); err != nil {
return fmt.Errorf("failed to create base directory: %w", err)
@@ -213,7 +213,7 @@ func (s *Seqlog) discoverExistingTopics() error {
}
// Stop 停止所有 processor
func (s *Seqlog) Stop() error {
func (s *LogHub) Stop() error {
s.mu.Lock()
if !s.running {
s.mu.Unlock()
@@ -250,7 +250,7 @@ func (s *Seqlog) Stop() error {
// getOrCreateProcessor 获取或创建指定 topic 的 processor使用默认配置
// 如果没有 defaultHandler使用空 handlerno-op
func (s *Seqlog) getOrCreateProcessor(topic string) (*TopicProcessor, error) {
func (s *LogHub) getOrCreateProcessor(topic string) (*TopicProcessor, error) {
// 创建默认配置
var config *TopicConfig
if s.defaultHandler != nil {
@@ -284,7 +284,7 @@ func (s *Seqlog) getOrCreateProcessor(topic string) (*TopicProcessor, error) {
}
// getOrCreateProcessorWithConfig 获取或创建指定 topic 的 processor使用指定配置
func (s *Seqlog) getOrCreateProcessorWithConfig(topic string, config *TopicConfig) (*TopicProcessor, error) {
func (s *LogHub) getOrCreateProcessorWithConfig(topic string, config *TopicConfig) (*TopicProcessor, error) {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
@@ -330,7 +330,7 @@ func (s *Seqlog) getOrCreateProcessorWithConfig(topic string, config *TopicConfi
}
// GetTopics 获取所有已知的 topic
func (s *Seqlog) GetTopics() []string {
func (s *LogHub) GetTopics() []string {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -341,54 +341,54 @@ func (s *Seqlog) GetTopics() []string {
return topics
}
// IsRunning 检查 Seqlog 是否正在运行
func (s *Seqlog) IsRunning() bool {
// IsRunning 检查 LogHub 是否正在运行
func (s *LogHub) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.running
}
// UpdateTopicConfig 动态更新指定 topic 的 tail 配置
func (s *Seqlog) UpdateTopicConfig(topic string, config *TailConfig) error {
func (s *LogHub) UpdateTopicConfig(topic string, config *TailConfig) error {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("topic %s not found", topic)
return NewTopicError(topic, "operation", ErrNotFound)
}
return processor.UpdateTailConfig(config)
}
// GetTopicConfig 获取指定 topic 的 tail 配置
func (s *Seqlog) GetTopicConfig(topic string) (*TailConfig, error) {
func (s *LogHub) GetTopicConfig(topic string) (*TailConfig, error) {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("topic %s not found", topic)
return nil, NewTopicError(topic, "get", ErrNotFound)
}
return processor.GetTailConfig(), nil
}
// GetTopicStats 获取指定 topic 的统计信息
func (s *Seqlog) GetTopicStats(topic string) (Stats, error) {
func (s *LogHub) GetTopicStats(topic string) (Stats, error) {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
if !exists {
return Stats{}, fmt.Errorf("topic %s not found", topic)
return Stats{}, NewTopicError(topic, "get-stats", ErrNotFound)
}
return processor.GetStats(), nil
}
// GetAllStats 获取所有 topic 的统计信息
func (s *Seqlog) GetAllStats() map[string]Stats {
func (s *LogHub) GetAllStats() map[string]Stats {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -400,20 +400,20 @@ func (s *Seqlog) GetAllStats() map[string]Stats {
}
// NewTopicQuery 为指定 topic 获取查询器(返回共享实例)
func (s *Seqlog) NewTopicQuery(topic string) (*RecordQuery, error) {
func (s *LogHub) NewTopicQuery(topic string) (*RecordQuery, error) {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("topic %s not found", topic)
return nil, NewTopicError(topic, "get", ErrNotFound)
}
return processor.Query(), nil
return processor.Query()
}
// GetProcessingIndex 获取指定 topic 的当前处理索引
func (s *Seqlog) GetProcessingIndex(topic string) int {
func (s *LogHub) GetProcessingIndex(topic string) int {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
@@ -426,7 +426,7 @@ func (s *Seqlog) GetProcessingIndex(topic string) int {
}
// GetReadIndex 获取指定 topic 的当前读取索引
func (s *Seqlog) GetReadIndex(topic string) int {
func (s *LogHub) GetReadIndex(topic string) int {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
@@ -439,20 +439,56 @@ func (s *Seqlog) GetReadIndex(topic string) int {
}
// GetProcessor 获取指定 topic 的 processor
func (s *Seqlog) GetProcessor(topic string) (*TopicProcessor, error) {
func (s *LogHub) GetProcessor(topic string) (*TopicProcessor, error) {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("topic %s not found", topic)
return nil, NewTopicError(topic, "get", ErrNotFound)
}
return processor, nil
}
// QueryFromProcessing 从当前处理窗口的开始位置向索引递增方向查询记录
// topic: 主题名称
// count: 查询数量
func (s *LogHub) QueryFromProcessing(topic string, count int) ([]*RecordWithStatus, error) {
processor, err := s.GetProcessor(topic)
if err != nil {
return nil, err
}
return processor.QueryFromProcessing(count)
}
// QueryFromFirst 从第一条记录向索引递增方向查询
// topic: 主题名称
// count: 查询数量
func (s *LogHub) QueryFromFirst(topic string, count int) ([]*RecordWithStatus, error) {
processor, err := s.GetProcessor(topic)
if err != nil {
return nil, err
}
return processor.QueryFromFirst(count)
}
// QueryFromLast 从最后一条记录向索引递减方向查询
// topic: 主题名称
// count: 查询数量
func (s *LogHub) QueryFromLast(topic string, count int) ([]*RecordWithStatus, error) {
processor, err := s.GetProcessor(topic)
if err != nil {
return nil, err
}
return processor.QueryFromLast(count)
}
// Subscribe 为指定 topic 订阅事件(如果 topic 不存在,会在创建时应用订阅)
func (s *Seqlog) Subscribe(topic string, eventType EventType, listener EventListener) error {
func (s *LogHub) Subscribe(topic string, eventType EventType, listener EventListener) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -476,7 +512,7 @@ func (s *Seqlog) Subscribe(topic string, eventType EventType, listener EventList
}
// SubscribeAll 为指定 topic 订阅所有事件
func (s *Seqlog) SubscribeAll(topic string, listener EventListener) error {
func (s *LogHub) SubscribeAll(topic string, listener EventListener) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -510,7 +546,7 @@ func (s *Seqlog) SubscribeAll(topic string, listener EventListener) error {
}
// SubscribeAllTopics 为所有 topic 订阅指定事件
func (s *Seqlog) SubscribeAllTopics(eventType EventType, listener EventListener) {
func (s *LogHub) SubscribeAllTopics(eventType EventType, listener EventListener) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -525,32 +561,29 @@ func (s *Seqlog) SubscribeAllTopics(eventType EventType, listener EventListener)
}
// ResetTopic 重置指定 topic 的所有数据
// 注意:必须先停止 Seqlog 或至少停止该 topic 的 processor
func (s *Seqlog) ResetTopic(topic string) error {
// 如果 processor 正在运行且没有待处理的日志,会自动停止后重置
// 如果有待处理的日志,则返回错误
func (s *LogHub) ResetTopic(topic string) error {
s.mu.RLock()
processor, exists := s.processors[topic]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("topic %s not found", topic)
return NewTopicError(topic, "operation", ErrNotFound)
}
// 先停止 processor
if err := processor.Stop(); err != nil {
return fmt.Errorf("failed to stop processor: %w", err)
}
// 执行重置
// 执行重置(如果没有待处理的日志会自动停止)
if err := processor.Reset(); err != nil {
return fmt.Errorf("failed to reset processor: %w", err)
}
// 如果 seqlog 正在运行,重新启动 processor
// 如果 LogHub 正在运行且 processor 未运行,启动 processor
// 注意:如果 Reset() 已经自动恢复到 Running 状态,就不需要再启动
s.mu.RLock()
running := s.running
s.mu.RUnlock()
if running {
if running && processor.GetState() != StateRunning {
if err := processor.Start(); err != nil {
return fmt.Errorf("failed to restart processor: %w", err)
}

374
query.go
View File

@@ -5,6 +5,9 @@ import (
"fmt"
"io"
"os"
"unicode/utf8"
"github.com/google/uuid"
)
// RecordStatus 记录处理状态
@@ -36,7 +39,29 @@ func (s RecordStatus) String() string {
// RecordWithStatus 带状态的记录
type RecordWithStatus struct {
Record *Record
Status RecordStatus
Index int // 记录在日志文件中的索引位置
Status RecordStatus // 记录的处理状态
}
// RecordWithIndex 带索引的记录
type RecordWithIndex struct {
Record *Record
Index int // 记录在日志文件中的索引位置
}
// RecordMetadata 记录元数据(不包含完整数据)
type RecordMetadata struct {
Index int // 记录索引
UUID uuid.UUID // UUID
DataSize uint32 // 数据大小(字节)
DataPreview string // 数据预览(前 200 个字符)
Full bool
}
// RecordMetadataWithStatus 带状态的记录元数据
type RecordMetadataWithStatus struct {
Metadata *RecordMetadata
Status RecordStatus // 记录的处理状态
}
// RecordQuery 记录查询器
@@ -45,13 +70,17 @@ type RecordQuery struct {
fd *os.File
rbuf []byte // 复用读缓冲区
index *RecordIndex // 索引文件管理器(来自外部)
writer *LogWriter // 日志写入器(来自外部)
}
// NewRecordQuery 创建记录查询器
// index 参数必须由外部提供,确保所有组件使用同一个索引实例
func NewRecordQuery(logPath string, index *RecordIndex) (*RecordQuery, error) {
func NewRecordQuery(logPath string, index *RecordIndex, writer *LogWriter) (*RecordQuery, error) {
if index == nil {
return nil, fmt.Errorf("index cannot be nil")
return nil, NewValidationError("index", "index cannot be nil", ErrNilParameter)
}
if writer == nil {
return nil, NewValidationError("writer", "writer cannot be nil", ErrNilParameter)
}
fd, err := os.Open(logPath)
@@ -64,11 +93,103 @@ func NewRecordQuery(logPath string, index *RecordIndex) (*RecordQuery, error) {
fd: fd,
rbuf: make([]byte, 8<<20), // 8 MiB 缓冲区
index: index,
writer: writer,
}
return rq, nil
}
// readRecordsMetadataForward 从指定索引位置向前顺序读取记录元数据(不读取完整 Data但读取预览
// startIndex: 起始记录索引
// count: 读取数量
func (rq *RecordQuery) readRecordsMetadataForward(startIndex, count int) ([]*RecordMetadata, error) {
// 获取起始 offset
startOffset, err := rq.index.GetOffset(startIndex)
if err != nil {
return nil, fmt.Errorf("get start offset: %w", err)
}
if _, err := rq.fd.Seek(startOffset, 0); err != nil {
return nil, fmt.Errorf("seek to offset %d: %w", startOffset, err)
}
results := make([]*RecordMetadata, 0, count)
currentIndex := startIndex
for len(results) < count {
// 读取头部:[4B len][8B offset][4B CRC][16B UUID] = 32 字节
hdr := rq.rbuf[:32]
if _, err := io.ReadFull(rq.fd, hdr); err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("read header at index %d: %w", currentIndex, err)
}
dataOffset := binary.LittleEndian.Uint64(hdr[4:12])
// 写入保护:如果 writer 存在,检查是否正在写入该记录
if rq.writer != nil {
dirtyOffset := rq.writer.GetDirtyOffset()
// 如果正在写入dirtyOffset >= 0且记录位置 >= 写入位置,等待写入完成
if dirtyOffset >= 0 && dataOffset >= uint64(dirtyOffset) {
break
}
}
dataLen := binary.LittleEndian.Uint32(hdr[0:4])
var uuidBytes [16]byte
copy(uuidBytes[:], hdr[16:32])
// 读取数据预览(最多 200 字节)
previewSize := min(int(dataLen), 200)
previewData := make([]byte, previewSize)
if _, err := io.ReadFull(rq.fd, previewData); err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("read preview at index %d: %w", currentIndex, err)
}
// 确保预览数据不会在 UTF-8 字符中间截断
validPreviewSize := previewSize
if previewSize > 0 && previewSize < int(dataLen) {
// 只有在截断的情况下才需要检查
// 从后往前最多检查 3 个字节,找到最后一个完整的 UTF-8 字符边界
for i := 0; i < 3 && validPreviewSize > 0; i++ {
if utf8.Valid(previewData[:validPreviewSize]) {
break
}
validPreviewSize--
}
}
metadata := &RecordMetadata{
Index: currentIndex,
UUID: uuidBytes,
DataSize: dataLen,
DataPreview: string(previewData[:validPreviewSize]),
Full: previewSize == int(dataLen),
}
// 跳过剩余数据部分
remainingSize := int64(dataLen) - int64(previewSize)
if remainingSize > 0 {
if _, err := rq.fd.Seek(remainingSize, 1); err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("skip remaining data at index %d: %w", currentIndex, err)
}
}
results = append(results, metadata)
currentIndex++
}
return results, nil
}
// readRecordsForward 从指定索引位置向前顺序读取记录
// startIndex: 起始记录索引
// count: 读取数量
@@ -87,8 +208,8 @@ func (rq *RecordQuery) readRecordsForward(startIndex, count int) ([]*Record, err
currentOffset := startOffset
for len(results) < count {
// 读取头部:[4B len][4B CRC][16B UUID] = 24 字节
hdr := rq.rbuf[:24]
// 读取头部:[4B len][8B offset][4B CRC][16B UUID] = 32 字节
hdr := rq.rbuf[:32]
if _, err := io.ReadFull(rq.fd, hdr); err != nil {
if err == io.EOF {
break
@@ -98,43 +219,103 @@ func (rq *RecordQuery) readRecordsForward(startIndex, count int) ([]*Record, err
rec := &Record{
Len: binary.LittleEndian.Uint32(hdr[0:4]),
CRC: binary.LittleEndian.Uint32(hdr[4:8]),
// hdr[4:12] 是 offset读取时不需要使用
CRC: binary.LittleEndian.Uint32(hdr[12:16]),
}
copy(rec.UUID[:], hdr[8:24])
copy(rec.UUID[:], hdr[16:32])
// 读取数据
rec.Data = make([]byte, rec.Len)
if _, err := io.ReadFull(rq.fd, rec.Data); err != nil {
// 如果遇到 EOF说明文件可能不完整被截断或索引不一致
// 返回已读取的记录,而不是报错
if err == io.EOF || err == io.ErrUnexpectedEOF {
break
}
return nil, fmt.Errorf("read data at offset %d: %w", currentOffset, err)
}
results = append(results, rec)
currentOffset += 24 + int64(rec.Len)
currentOffset += 32 + int64(rec.Len)
}
return results, nil
}
// QueryOldest 从指定索引开始查询记录(向前读取
// startIndex: 查询起始索引
// QueryOldest 从参考索引向索引递减方向查询记录(查询更早的记录
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录按时间顺序(索引递增方向
func (rq *RecordQuery) QueryOldest(startIndex, count int) ([]*Record, error) {
// 返回的记录按索引递增方向排序,包含索引信息
// 例如QueryOldest(5, 3) 查询索引 2, 3, 4不包含 5返回 [2, 3, 4]
func (rq *RecordQuery) QueryOldest(refIndex, count int) ([]*RecordWithIndex, error) {
if count <= 0 {
return nil, fmt.Errorf("count must be greater than 0")
return nil, NewValidationError("count", "count must be greater than 0", ErrInvalidCount)
}
totalCount := rq.index.Count()
if totalCount == 0 {
return []*Record{}, nil
return []*RecordWithIndex{}, nil
}
// 校验起始索引
// 验证参考索引范围(严格模式)
if refIndex < 0 || refIndex > totalCount {
return nil, NewValidationError("refIndex", fmt.Sprintf("refIndex %d out of range [0, %d]", refIndex, totalCount), ErrInvalidRange)
}
// 计算实际起始索引(向索引递减方向)
startIndex := refIndex - count
if startIndex < 0 {
startIndex = 0
count = refIndex // 调整实际数量
}
if count <= 0 {
return []*RecordWithIndex{}, nil
}
// 读取记录
records, err := rq.readRecordsForward(startIndex, count)
if err != nil {
return nil, err
}
// 转换为带索引的记录
results := make([]*RecordWithIndex, len(records))
for i, rec := range records {
results[i] = &RecordWithIndex{
Record: rec,
Index: startIndex + i,
}
}
return results, nil
}
// QueryNewest 从参考索引向索引递增方向查询记录(查询更新的记录)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录按索引递增方向排序,包含索引信息
// 例如QueryNewest(5, 3) 查询索引 6, 7, 8不包含 5返回 [6, 7, 8]
func (rq *RecordQuery) QueryNewest(refIndex, count int) ([]*RecordWithIndex, error) {
if count <= 0 {
return nil, NewValidationError("count", "count must be greater than 0", ErrInvalidCount)
}
totalCount := rq.index.Count()
if totalCount == 0 {
return []*RecordWithIndex{}, nil
}
// 验证参考索引范围(严格模式)
// QueryNewest 允许 refIndex = -1从头开始查询
if refIndex < -1 || refIndex >= totalCount {
return nil, NewValidationError("refIndex", fmt.Sprintf("refIndex %d out of range [-1, %d)", refIndex, totalCount), ErrInvalidRange)
}
// 计算实际起始索引(向索引递增方向)
startIndex := refIndex + 1
if startIndex >= totalCount {
return []*Record{}, nil
return []*RecordWithIndex{}, nil
}
// 限制查询数量
@@ -143,52 +324,118 @@ func (rq *RecordQuery) QueryOldest(startIndex, count int) ([]*Record, error) {
count = remainCount
}
return rq.readRecordsForward(startIndex, count)
}
// QueryNewest 从指定索引开始向后查询记录(索引递减方向)
// endIndex: 查询结束索引(包含,最新的记录)
// count: 查询数量
// 返回结果按时间倒序(最新在前,即 endIndex 对应的记录在最前)
func (rq *RecordQuery) QueryNewest(endIndex, count int) ([]*Record, error) {
if count <= 0 {
return nil, fmt.Errorf("count must be greater than 0")
}
totalCount := rq.index.Count()
if totalCount == 0 {
return []*Record{}, nil
}
// 校验结束索引
if endIndex < 0 {
return []*Record{}, nil
}
if endIndex >= totalCount {
endIndex = totalCount - 1
}
// 计算实际起始索引(向前推 count-1 条)
queryStartIdx := endIndex - count + 1
if queryStartIdx < 0 {
queryStartIdx = 0
count = endIndex + 1 // 调整实际数量
}
// 向前读取
results, err := rq.readRecordsForward(queryStartIdx, count)
// 读取记录
records, err := rq.readRecordsForward(startIndex, count)
if err != nil {
return nil, err
}
// 反转结果,使最新的在前
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
// 转换为带索引的记录
results := make([]*RecordWithIndex, len(records))
for i, rec := range records {
results[i] = &RecordWithIndex{
Record: rec,
Index: startIndex + i,
}
}
return results, nil
}
// QueryOldestMetadata 从参考索引向索引递减方向查询记录元数据(查询更早的记录,不读取完整数据)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录按索引递增方向排序,只包含元数据信息
// 例如QueryOldestMetadata(5, 3) 查询索引 2, 3, 4不包含 5返回 [2, 3, 4]
func (rq *RecordQuery) QueryOldestMetadata(refIndex, count int) ([]*RecordMetadata, error) {
if count <= 0 {
return nil, NewValidationError("count", "count must be greater than 0", ErrInvalidCount)
}
totalCount := rq.index.Count()
if totalCount == 0 {
return []*RecordMetadata{}, nil
}
// 验证参考索引范围(严格模式)
if refIndex < 0 || refIndex > totalCount {
return nil, NewValidationError("refIndex", fmt.Sprintf("refIndex %d out of range [0, %d]", refIndex, totalCount), ErrInvalidRange)
}
// 计算实际起始索引(向索引递减方向)
startIndex := refIndex - count
if startIndex < 0 {
startIndex = 0
count = refIndex // 调整实际数量
}
if count <= 0 {
return []*RecordMetadata{}, nil
}
// 读取元数据
return rq.readRecordsMetadataForward(startIndex, count)
}
// QueryNewestMetadata 从参考索引向索引递增方向查询记录元数据(查询更新的记录,不读取完整数据)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录按索引递增方向排序,只包含元数据信息
// 例如QueryNewestMetadata(5, 3) 查询索引 6, 7, 8不包含 5返回 [6, 7, 8]
func (rq *RecordQuery) QueryNewestMetadata(refIndex, count int) ([]*RecordMetadata, error) {
if count <= 0 {
return nil, NewValidationError("count", "count must be greater than 0", ErrInvalidCount)
}
totalCount := rq.index.Count()
if totalCount == 0 {
return []*RecordMetadata{}, nil
}
// 验证参考索引范围(严格模式)
// QueryNewestMetadata 允许 refIndex = -1从头开始查询
if refIndex < -1 || refIndex >= totalCount {
return nil, NewValidationError("refIndex", fmt.Sprintf("refIndex %d out of range [-1, %d)", refIndex, totalCount), ErrInvalidRange)
}
// 计算实际起始索引(向索引递增方向)
startIndex := refIndex + 1
if startIndex >= totalCount {
return []*RecordMetadata{}, nil
}
// 限制查询数量
remainCount := totalCount - startIndex
if count > remainCount {
count = remainCount
}
// 读取元数据
return rq.readRecordsMetadataForward(startIndex, count)
}
// QueryByIndex 根据索引查询单条记录的完整数据
// index: 记录索引
// 返回完整的记录数据
func (rq *RecordQuery) QueryByIndex(index int) (*Record, error) {
totalCount := rq.index.Count()
if index < 0 || index >= totalCount {
return nil, NewValidationError("index", fmt.Sprintf("index %d out of range [0, %d)", index, totalCount), ErrInvalidRange)
}
// 读取单条记录
records, err := rq.readRecordsForward(index, 1)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, fmt.Errorf("record at index %d not found", index)
}
return records[0], nil
}
// GetRecordCount 获取记录总数
func (rq *RecordQuery) GetRecordCount() (int, error) {
return rq.index.Count(), nil
@@ -217,3 +464,24 @@ func (rq *RecordQuery) Close() error {
}
return nil
}
// Reset 重置查询器,关闭并重新打开日志文件
// 保持 index 和 writer 引用不变
func (rq *RecordQuery) Reset() error {
// 关闭当前文件句柄
if rq.fd != nil {
if err := rq.fd.Close(); err != nil {
return err
}
rq.fd = nil
}
// 重新打开日志文件
fd, err := os.Open(rq.logPath)
if err != nil {
return fmt.Errorf("reopen log file: %w", err)
}
rq.fd = fd
return nil
}

75
query_test.go Normal file
View File

@@ -0,0 +1,75 @@
package seqlog
import (
"fmt"
"testing"
"time"
)
// TestQueryFromProcessing 测试从处理窗口开始位置查询记录
func TestQueryFromProcessing(t *testing.T) {
// 创建临时目录
tmpDir := t.TempDir()
// 创建配置(带 handler不处理任何记录以保持窗口稳定
config := &TopicConfig{
Handler: func(record *Record) error {
// 不处理,只是为了让 tailer 启动
time.Sleep(1 * time.Second) // 延迟处理
return nil
},
}
// 创建 TopicProcessor
processor, err := NewTopicProcessor(tmpDir, "test", nil, config)
if err != nil {
t.Fatal(err)
}
defer processor.Close()
// 写入 10 条记录
for i := 0; i < 10; i++ {
msg := fmt.Sprintf("message %d", i)
_, err := processor.Write([]byte(msg))
if err != nil {
t.Fatal(err)
}
}
// 不启动 tailer直接测试查询功能
// startIdx 应该是 0没有处理任何记录
// 从处理窗口开始位置查询 5 条记录
records, err := processor.QueryFromProcessing(5)
if err != nil {
t.Fatal(err)
}
t.Logf("查询到 %d 条记录", len(records))
if len(records) != 5 {
t.Fatalf("expected 5 records, got %d", len(records))
}
// 验证查询结果从索引 0 开始
for i, rec := range records {
expectedIndex := i
if rec.Index != expectedIndex {
t.Errorf("record[%d]: expected index %d, got %d", i, expectedIndex, rec.Index)
}
expectedMsg := fmt.Sprintf("message %d", expectedIndex)
if string(rec.Record.Data) != expectedMsg {
t.Errorf("record[%d]: expected data '%s', got '%s'", i, expectedMsg, string(rec.Record.Data))
}
// 未启动 tailer所有记录都应该是 Pending 状态
if rec.Status != StatusPending {
t.Errorf("record[%d]: expected StatusPending, got %s", i, rec.Status)
}
t.Logf(" [索引 %d] %s - 状态: %s", rec.Index, string(rec.Record.Data), rec.Status)
}
t.Log("QueryFromProcessing 测试通过")
}

View File

@@ -42,13 +42,13 @@ import "github.com/google/uuid"
// tailer, _ := seqlog.NewTailer("app.log", handler, nil)
// tailer.Start()
//
// // 使用 Seqlog 管理器(带 slog 支持和自动恢复)
// // 使用 LogHub 管理器(带 slog 支持和自动恢复)
// logger := slog.Default()
// handler := func(topic string, rec *seqlog.Record) error {
// fmt.Printf("[%s] %s\n", topic, string(rec.Data))
// return nil
// }
// seq := seqlog.NewSeqlog("/tmp/logs", logger, handler)
// seq := seqlog.NewLogHub("/tmp/logs", logger, handler)
// seq.Start() // 自动发现并恢复已存在的日志文件
// seq.Write("app", []byte("application log"))
//
@@ -72,8 +72,7 @@ import "github.com/google/uuid"
// Record 日志记录
//
// 存储格式:[4B len][4B CRC][16B UUID][data]
// 注意Offset 不存储在数据文件中,而是由索引文件管理
// 存储格式:[4B len][8B offset][4B CRC][16B UUID][data]
type Record struct {
Len uint32 // 数据长度
CRC uint32 // CRC 校验和

View File

@@ -44,7 +44,7 @@ func TestBasicWriteAndRead(t *testing.T) {
}
// 读取数据(使用共享的 index
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("创建 cursor 失败: %v", err)
}
@@ -90,7 +90,7 @@ func TestCursorNextRange(t *testing.T) {
writer.Close()
// 测试范围读取
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatal(err)
}
@@ -176,7 +176,7 @@ func TestCursorWindow(t *testing.T) {
writer.Close()
// 测试窗口模式
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatal(err)
}
@@ -280,19 +280,19 @@ func TestCursorPersistence(t *testing.T) {
}
// 读取前两条记录
cursor1, err := NewCursor(tmpFile, index)
cursor1, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("创建 cursor 失败: %v", err)
}
cursor1.Next() // 读取第一条
cursor1.Next() // 读取第一条
cursor1.Commit() // 提交
cursor1.Next() // 读取第二条
cursor1.Next() // 读取第二条
cursor1.Commit() // 提交
cursor1.Close()
// 重新打开 cursor应该从第三条开始读取
cursor2, err := NewCursor(tmpFile, index)
cursor2, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("重新创建 cursor 失败: %v", err)
}
@@ -337,7 +337,7 @@ func TestTailer(t *testing.T) {
}
// 创建 cursor
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("创建 cursor 失败: %v", err)
}
@@ -407,7 +407,7 @@ func TestTailerStop(t *testing.T) {
return nil
}
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("创建 cursor 失败: %v", err)
}
@@ -437,7 +437,7 @@ func TestSeqlogBasic(t *testing.T) {
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
seqlog := NewSeqlog(tmpDir, slog.Default(), nil)
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
// 注册 handler
appLogs := make([]string, 0)
@@ -493,7 +493,7 @@ func TestSeqlogDefaultHandler(t *testing.T) {
return nil
}
seqlog := NewSeqlog(tmpDir, slog.Default(), defaultHandler)
seqlog := NewLogHub(tmpDir, slog.Default(), defaultHandler)
// 注册特定 handler
seqlog.RegisterHandler("special", func(rec *Record) error {
@@ -534,7 +534,7 @@ func TestSeqlogDynamicRegistration(t *testing.T) {
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
seqlog := NewSeqlog(tmpDir, slog.Default(), nil)
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
// 先注册 handlerhandler 现在是必填项)
logs := make([]string, 0)
@@ -566,7 +566,7 @@ func TestDynamicConfigUpdate(t *testing.T) {
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
seqlog := NewSeqlog(tmpDir, slog.Default(), nil)
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
// 注册 handler
logs := make([]string, 0)
@@ -650,7 +650,7 @@ func TestUUIDUniqueness(t *testing.T) {
}
// 读取并验证 UUID 唯一性
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("创建 cursor 失败: %v", err)
}
@@ -705,7 +705,7 @@ func TestUUIDValidation(t *testing.T) {
}
// 读取并验证 UUID
cursor, err := NewCursor(tmpFile, index)
cursor, err := NewCursor(tmpFile, index, nil)
if err != nil {
t.Fatalf("创建 cursor 失败: %v", err)
}
@@ -746,7 +746,7 @@ func TestSeqlogAutoRecovery(t *testing.T) {
return nil
}
seqlog1 := NewSeqlog(tmpDir, slog.Default(), defaultHandler)
seqlog1 := NewLogHub(tmpDir, slog.Default(), defaultHandler)
seqlog1.Start()
// 写入一些日志
@@ -777,7 +777,7 @@ func TestSeqlogAutoRecovery(t *testing.T) {
mu.Unlock()
// 第二阶段:重启并自动恢复
seqlog2 := NewSeqlog(tmpDir, slog.Default(), defaultHandler)
seqlog2 := NewLogHub(tmpDir, slog.Default(), defaultHandler)
seqlog2.Start()
// 写入新日志
@@ -880,7 +880,7 @@ func TestSeqlogCleanup(t *testing.T) {
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
seqlog := NewSeqlog(tmpDir, slog.Default(), nil)
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
seqlog.Start()
// 写入多个 topic 的日志
@@ -1118,7 +1118,7 @@ func TestSeqlogStats(t *testing.T) {
return nil
}
seq := NewSeqlog(tmpDir, slog.Default(), handler)
seq := NewLogHub(tmpDir, slog.Default(), handler)
if err := seq.Start(); err != nil {
t.Fatalf("failed to start seqlog: %v", err)
}
@@ -1208,7 +1208,7 @@ func TestRecordQuery(t *testing.T) {
}
offsets[i] = offset
}
writer.Close()
defer writer.Close()
// 模拟处理到第 5 条记录
// 窗口范围:[索引 5, 索引 6)
@@ -1223,22 +1223,25 @@ func TestRecordQuery(t *testing.T) {
defer index.Close()
// 创建查询器
query, err := NewRecordQuery(tmpFile, index)
query, err := NewRecordQuery(tmpFile, index, writer)
if err != nil {
t.Fatalf("failed to create query: %v", err)
}
defer query.Close()
// 测试查询当前位置
current, err := query.QueryOldest(startIdx, 1)
// 测试查询当前位置(使用 QueryNewest 查询 startIdx
current, err := query.QueryNewest(startIdx-1, 1)
if err != nil {
t.Fatalf("failed to query current: %v", err)
}
if len(current) != 1 {
t.Fatalf("expected 1 current result, got %d", len(current))
}
if string(current[0].Data) != "message 5" {
t.Errorf("expected current 'message 5', got '%s'", string(current[0].Data))
if string(current[0].Record.Data) != "message 5" {
t.Errorf("expected current 'message 5', got '%s'", string(current[0].Record.Data))
}
if current[0].Index != startIdx {
t.Errorf("expected index %d, got %d", startIdx, current[0].Index)
}
// 手动判断状态
status := GetRecordStatus(startIdx, startIdx, endIdx)
@@ -1246,43 +1249,53 @@ func TestRecordQuery(t *testing.T) {
t.Errorf("expected status Processing, got %s", status)
}
// 测试向后查询(查询更早的记录,返回倒序
backResults, err := query.QueryNewest(startIdx-1, 3)
// 测试 QueryOldest查询更早的记录向索引递减方向
// QueryOldest(5, 3) 查询索引 2, 3, 4
backResults, err := query.QueryOldest(startIdx, 3)
if err != nil {
t.Fatalf("failed to query backward: %v", err)
}
if len(backResults) != 3 {
t.Errorf("expected 3 backward results, got %d", len(backResults))
}
// 向后查询返回倒序结果newest first
expectedBack := []string{"message 4", "message 3", "message 2"}
// 返回按索引递增排序的结果2, 3, 4
expectedBack := []string{"message 2", "message 3", "message 4"}
for i, rec := range backResults {
if string(rec.Data) != expectedBack[i] {
t.Errorf("backward[%d]: expected '%s', got '%s'", i, expectedBack[i], string(rec.Data))
if string(rec.Record.Data) != expectedBack[i] {
t.Errorf("backward[%d]: expected '%s', got '%s'", i, expectedBack[i], string(rec.Record.Data))
}
// 手动判断状态:索引 4, 3, 2
recStatus := GetRecordStatus(startIdx-1-i, startIdx, endIdx)
expectedIndex := startIdx - 3 + i
if rec.Index != expectedIndex {
t.Errorf("backward[%d]: expected index %d, got %d", i, expectedIndex, rec.Index)
}
// 手动判断状态:索引 2, 3, 4 都已处理
recStatus := GetRecordStatus(rec.Index, startIdx, endIdx)
if recStatus != StatusProcessed {
t.Errorf("backward[%d]: expected status Processed, got %s", i, recStatus)
}
}
// 测试向前查询(查询更新的记录
forwardResults, err := query.QueryOldest(endIdx, 3)
// 测试 QueryNewest查询更新的记录向索引递增方向
// QueryNewest(endIdx, 3) 从 endIdx 向后查询,查询索引 6, 7, 8
forwardResults, err := query.QueryNewest(endIdx-1, 3)
if err != nil {
t.Fatalf("failed to query forward: %v", err)
}
if len(forwardResults) != 3 {
t.Errorf("expected 3 forward results, got %d", len(forwardResults))
}
// 向前查询返回顺序结果
// 返回按索引递增排序的结果6, 7, 8
expectedForward := []string{"message 6", "message 7", "message 8"}
for i, rec := range forwardResults {
if string(rec.Data) != expectedForward[i] {
t.Errorf("forward[%d]: expected '%s', got '%s'", i, expectedForward[i], string(rec.Data))
if string(rec.Record.Data) != expectedForward[i] {
t.Errorf("forward[%d]: expected '%s', got '%s'", i, expectedForward[i], string(rec.Record.Data))
}
// 手动判断状态:索引 6, 7, 8
recStatus := GetRecordStatus(endIdx+i, startIdx, endIdx)
expectedIndex := endIdx + i
if rec.Index != expectedIndex {
t.Errorf("forward[%d]: expected index %d, got %d", i, expectedIndex, rec.Index)
}
// 手动判断状态:索引 6, 7, 8 待处理
recStatus := GetRecordStatus(rec.Index, startIdx, endIdx)
if recStatus != StatusPending {
t.Errorf("forward[%d]: expected status Pending, got %s", i, recStatus)
}
@@ -1384,7 +1397,7 @@ func TestSeqlogQuery(t *testing.T) {
return nil
}
seq := NewSeqlog(tmpDir, slog.Default(), handler)
seq := NewLogHub(tmpDir, slog.Default(), handler)
if err := seq.Start(); err != nil {
t.Fatalf("failed to start seqlog: %v", err)
}
@@ -1414,7 +1427,7 @@ func TestSeqlogQuery(t *testing.T) {
// 获取 processor 用于查询(带状态)
processor, _ := seq.GetProcessor("app")
index := processor.Index()
totalCount := processor.GetRecordCount()
// 测试查询当前
if startIdx < endIdx {
@@ -1439,7 +1452,7 @@ func TestSeqlogQuery(t *testing.T) {
}
// 测试向前查询
if startIdx < index.Count() {
if startIdx < totalCount {
forward, err := processor.QueryOldest(endIdx, 3)
if err != nil {
t.Fatalf("failed to query forward: %v", err)
@@ -1569,7 +1582,7 @@ func TestSeqlogEventSubscription(t *testing.T) {
return nil
}
seq := NewSeqlog(tmpDir, slog.Default(), handler)
seq := NewLogHub(tmpDir, slog.Default(), handler)
if err := seq.Start(); err != nil {
t.Fatalf("failed to start seqlog: %v", err)
}
@@ -1612,7 +1625,7 @@ func TestMultiTopicEventSubscription(t *testing.T) {
return nil
}
seq := NewSeqlog(tmpDir, slog.Default(), handler)
seq := NewLogHub(tmpDir, slog.Default(), handler)
if err := seq.Start(); err != nil {
t.Fatalf("failed to start seqlog: %v", err)
}
@@ -1658,7 +1671,7 @@ func TestMultiTopicEventSubscription(t *testing.T) {
func TestTopicReset(t *testing.T) {
tmpDir := t.TempDir()
seqlog := NewSeqlog(tmpDir, slog.Default(), nil)
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
// 注册 handler
seqlog.RegisterHandler("test", func(rec *Record) error {
@@ -1750,6 +1763,50 @@ func TestTopicReset(t *testing.T) {
seqlog.Stop()
}
// TestTopicResetWithPendingRecords 测试当有待处理日志时 Reset 返回错误
func TestTopicResetWithPendingRecords(t *testing.T) {
tmpDir := t.TempDir()
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
// 注册一个慢速 handler让日志堆积
slowHandler := func(rec *Record) error {
time.Sleep(100 * time.Millisecond) // 模拟慢速处理
return nil
}
seqlog.RegisterHandler("test", slowHandler)
seqlog.Start()
// 快速写入多条日志
for i := 0; i < 10; i++ {
data := []byte(fmt.Sprintf("message %d", i))
if _, err := seqlog.Write("test", data); err != nil {
t.Fatalf("写入失败: %v", err)
}
}
// 短暂等待,让一部分日志开始处理但不是全部
time.Sleep(200 * time.Millisecond)
// 尝试重置,应该失败因为有待处理的日志
err := seqlog.ResetTopic("test")
if err == nil {
t.Fatal("期望 Reset 失败因为有待处理的日志,但成功了")
}
t.Logf("预期的错误: %v", err)
// 停止处理
seqlog.Stop()
// 现在 Reset 应该成功(停止后没有待处理的日志)
processor, _ := seqlog.GetProcessor("test")
if err := processor.Reset(); err != nil {
t.Fatalf("停止后 Reset 应该成功: %v", err)
}
}
// TestQueryOldestNewest 测试 QueryOldest 和 QueryNewest
func TestQueryOldestNewest(t *testing.T) {
tmpDir := t.TempDir()
@@ -1764,7 +1821,7 @@ func TestQueryOldestNewest(t *testing.T) {
t.Fatal(err)
}
defer processor.Close()
// 写入测试数据
for i := 0; i < 10; i++ {
data := fmt.Sprintf("message %d", i)
@@ -1772,11 +1829,12 @@ func TestQueryOldestNewest(t *testing.T) {
t.Fatal(err)
}
}
// 测试 QueryOldest - 索引 0 开始查询 3 条
oldest, err := processor.QueryOldest(0, 3)
// 测试 QueryNewest - 查询索引 0, 1, 2向索引递增方向
// QueryNewest(-1, 3) 从 -1 向后查询,得到索引 0, 1, 2
oldest, err := processor.QueryNewest(-1, 3)
if err != nil {
t.Fatalf("QueryOldest failed: %v", err)
t.Fatalf("QueryNewest failed: %v", err)
}
if len(oldest) != 3 {
t.Errorf("expected 3 records, got %d", len(oldest))
@@ -1787,31 +1845,39 @@ func TestQueryOldestNewest(t *testing.T) {
if string(oldest[i].Record.Data) != expected {
t.Errorf("oldest[%d]: expected %s, got %s", i, expected, string(oldest[i].Record.Data))
}
t.Logf("Oldest[%d]: %s - %s", i, string(oldest[i].Record.Data), oldest[i].Status)
if oldest[i].Index != i {
t.Errorf("oldest[%d]: expected index %d, got %d", i, i, oldest[i].Index)
}
t.Logf("Oldest[%d]: index=%d, %s - %s", i, oldest[i].Index, string(oldest[i].Record.Data), oldest[i].Status)
}
// 测试 QueryNewest - 索引 9 结束查询 3 条
// 测试 QueryOldest - 查询索引 7, 8, 9向索引递减方向
// QueryOldest(10, 3) 从 10 向前查询,得到索引 7, 8, 9
totalCount := processor.GetRecordCount()
newest, err := processor.QueryNewest(totalCount-1, 3)
newest, err := processor.QueryOldest(totalCount, 3)
if err != nil {
t.Fatalf("QueryNewest failed: %v", err)
t.Fatalf("QueryOldest failed: %v", err)
}
if len(newest) != 3 {
t.Errorf("expected 3 records, got %d", len(newest))
}
// 验证顺序:应该是 9, 8, 7倒序
// 验证顺序:应该是 7, 8, 9按索引递增
for i := 0; i < 3; i++ {
expected := fmt.Sprintf("message %d", 9-i)
expected := fmt.Sprintf("message %d", 7+i)
if string(newest[i].Record.Data) != expected {
t.Errorf("newest[%d]: expected %s, got %s", i, expected, string(newest[i].Record.Data))
}
t.Logf("Newest[%d]: %s - %s", i, string(newest[i].Record.Data), newest[i].Status)
if newest[i].Index != 7+i {
t.Errorf("newest[%d]: expected index %d, got %d", i, 7+i, newest[i].Index)
}
t.Logf("Newest[%d]: index=%d, %s - %s", i, newest[i].Index, string(newest[i].Record.Data), newest[i].Status)
}
// 测试超出范围
all, err := processor.QueryOldest(0, 100)
// 测试超出范围 - 查询所有记录
// QueryNewest(-1, 100) 从 -1 向后查询,会返回所有记录(最多 100 条)
all, err := processor.QueryNewest(-1, 100)
if err != nil {
t.Fatalf("QueryOldest(0, 100) failed: %v", err)
t.Fatalf("QueryNewest(-1, 100) failed: %v", err)
}
if len(all) != 10 {
t.Errorf("expected 10 records, got %d", len(all))
@@ -1828,11 +1894,195 @@ func TestQueryOldestNewest(t *testing.T) {
}
defer processor2.Close()
emptyOldest, err := processor2.QueryOldest(0, 10)
emptyNewest, err := processor2.QueryNewest(-1, 10)
if err != nil {
t.Fatalf("QueryOldest on empty failed: %v", err)
t.Fatalf("QueryNewest on empty failed: %v", err)
}
if len(emptyOldest) != 0 {
t.Errorf("expected 0 records, got %d", len(emptyOldest))
if len(emptyNewest) != 0 {
t.Errorf("expected 0 records, got %d", len(emptyNewest))
}
}
// TestQueryFromFirstAndLast 测试 QueryFromFirst 和 QueryFromLast
func TestQueryFromFirstAndLast(t *testing.T) {
tmpDir := t.TempDir()
// 创建 TopicProcessor
processor, err := NewTopicProcessor(tmpDir, "test", nil, &TopicConfig{
Handler: func(rec *Record) error {
return nil
},
})
if err != nil {
t.Fatal(err)
}
defer processor.Close()
// 写入 10 条测试数据
for i := 0; i < 10; i++ {
data := fmt.Sprintf("message %d", i)
if _, err := processor.Write([]byte(data)); err != nil {
t.Fatal(err)
}
}
// 测试 QueryFromFirst - 从第一条记录向索引递增方向查询
t.Run("QueryFromFirst", func(t *testing.T) {
// 查询前 3 条记录
records, err := processor.QueryFromFirst(3)
if err != nil {
t.Fatalf("QueryFromFirst failed: %v", err)
}
if len(records) != 3 {
t.Fatalf("expected 3 records, got %d", len(records))
}
// 验证结果:应该是索引 0, 1, 2
for i := 0; i < 3; i++ {
expectedData := fmt.Sprintf("message %d", i)
if string(records[i].Record.Data) != expectedData {
t.Errorf("records[%d]: expected %s, got %s", i, expectedData, string(records[i].Record.Data))
}
if records[i].Index != i {
t.Errorf("records[%d]: expected index %d, got %d", i, i, records[i].Index)
}
t.Logf("FromFirst[%d]: index=%d, %s - %s", i, records[i].Index, string(records[i].Record.Data), records[i].Status)
}
// 查询超过总数的记录
allRecords, err := processor.QueryFromFirst(100)
if err != nil {
t.Fatalf("QueryFromFirst(100) failed: %v", err)
}
if len(allRecords) != 10 {
t.Errorf("expected 10 records, got %d", len(allRecords))
}
})
// 测试 QueryFromLast - 从最后一条记录向索引递减方向查询
t.Run("QueryFromLast", func(t *testing.T) {
// 查询最后 3 条记录
records, err := processor.QueryFromLast(3)
if err != nil {
t.Fatalf("QueryFromLast failed: %v", err)
}
if len(records) != 3 {
t.Fatalf("expected 3 records, got %d", len(records))
}
// 验证结果:应该是索引 7, 8, 9按索引递增顺序排列
for i := 0; i < 3; i++ {
expectedIndex := 7 + i
expectedData := fmt.Sprintf("message %d", expectedIndex)
if string(records[i].Record.Data) != expectedData {
t.Errorf("records[%d]: expected %s, got %s", i, expectedData, string(records[i].Record.Data))
}
if records[i].Index != expectedIndex {
t.Errorf("records[%d]: expected index %d, got %d", i, expectedIndex, records[i].Index)
}
t.Logf("FromLast[%d]: index=%d, %s - %s", i, records[i].Index, string(records[i].Record.Data), records[i].Status)
}
// 查询超过总数的记录
allRecords, err := processor.QueryFromLast(100)
if err != nil {
t.Fatalf("QueryFromLast(100) failed: %v", err)
}
if len(allRecords) != 10 {
t.Errorf("expected 10 records, got %d", len(allRecords))
}
})
// 测试空数据库
t.Run("EmptyDatabase", func(t *testing.T) {
emptyProcessor, err := NewTopicProcessor(t.TempDir(), "empty", nil, &TopicConfig{
Handler: func(rec *Record) error {
return nil
},
})
if err != nil {
t.Fatal(err)
}
defer emptyProcessor.Close()
// QueryFromFirst 应该返回空数组
firstRecords, err := emptyProcessor.QueryFromFirst(10)
if err != nil {
t.Fatalf("QueryFromFirst on empty failed: %v", err)
}
if len(firstRecords) != 0 {
t.Errorf("expected 0 records, got %d", len(firstRecords))
}
// QueryFromLast 应该返回空数组
lastRecords, err := emptyProcessor.QueryFromLast(10)
if err != nil {
t.Fatalf("QueryFromLast on empty failed: %v", err)
}
if len(lastRecords) != 0 {
t.Errorf("expected 0 records, got %d", len(lastRecords))
}
})
}
// TestLogHubQueryFromFirstAndLast 测试 LogHub 的 QueryFromFirst 和 QueryFromLast
func TestLogHubQueryFromFirstAndLast(t *testing.T) {
tmpDir := t.TempDir()
seqlog := NewLogHub(tmpDir, slog.Default(), nil)
seqlog.RegisterHandler("test", func(rec *Record) error {
return nil
})
seqlog.Start()
defer seqlog.Stop()
// 写入测试数据
for i := 0; i < 10; i++ {
data := fmt.Sprintf("message %d", i)
if _, err := seqlog.Write("test", []byte(data)); err != nil {
t.Fatal(err)
}
}
// 测试 QueryFromFirst
firstRecords, err := seqlog.QueryFromFirst("test", 3)
if err != nil {
t.Fatalf("QueryFromFirst failed: %v", err)
}
if len(firstRecords) != 3 {
t.Fatalf("expected 3 records, got %d", len(firstRecords))
}
for i := 0; i < 3; i++ {
if firstRecords[i].Index != i {
t.Errorf("firstRecords[%d]: expected index %d, got %d", i, i, firstRecords[i].Index)
}
}
// 测试 QueryFromLast
lastRecords, err := seqlog.QueryFromLast("test", 3)
if err != nil {
t.Fatalf("QueryFromLast failed: %v", err)
}
if len(lastRecords) != 3 {
t.Fatalf("expected 3 records, got %d", len(lastRecords))
}
for i := 0; i < 3; i++ {
expectedIndex := 7 + i
if lastRecords[i].Index != expectedIndex {
t.Errorf("lastRecords[%d]: expected index %d, got %d", i, expectedIndex, lastRecords[i].Index)
}
}
// 测试不存在的 topic
_, err = seqlog.QueryFromFirst("nonexistent", 10)
if err == nil {
t.Error("expected error for nonexistent topic")
}
_, err = seqlog.QueryFromLast("nonexistent", 10)
if err == nil {
t.Error("expected error for nonexistent topic")
}
}

View File

@@ -22,7 +22,7 @@ type TailConfig struct {
// LogTailer 持续监控处理器
type LogTailer struct {
cursor *LogCursor
cursor *ProcessCursor
handler RecordHandler
config TailConfig
configCh chan TailConfig // 用于动态更新配置
@@ -32,7 +32,7 @@ type LogTailer struct {
// NewTailer 创建一个新的 tail 处理器
// cursor: 外部提供的游标,用于读取和跟踪日志位置
func NewTailer(cursor *LogCursor, handler RecordHandler, config *TailConfig) (*LogTailer, error) {
func NewTailer(cursor *ProcessCursor, handler RecordHandler, config *TailConfig) (*LogTailer, error) {
if cursor == nil {
return nil, fmt.Errorf("cursor cannot be nil")
}
@@ -147,3 +147,19 @@ func (t *LogTailer) GetStartIndex() int {
func (t *LogTailer) GetEndIndex() int {
return t.cursor.EndIndex()
}
// Reset 重置 tailer 的内部状态
// 注意:调用前必须确保 tailer 已停止
func (t *LogTailer) Reset() error {
// 重新创建 channel确保没有遗留的信号
t.stopCh = make(chan struct{})
t.doneCh = make(chan struct{})
// 清空配置 channel
select {
case <-t.configCh:
default:
}
return nil
}

View File

@@ -4,41 +4,101 @@ import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// ProcessorState 处理器状态
type ProcessorState int
const (
StateIdle ProcessorState = iota // 空闲(未启动)
StateStarting // 启动中
StateRunning // 运行中
StateStopping // 停止中
StateStopped // 已停止
StateResetting // 重置中(阻止所有操作)
StateError // 错误状态
)
// String 返回状态的字符串表示
func (s ProcessorState) String() string {
switch s {
case StateIdle:
return "idle"
case StateStarting:
return "starting"
case StateRunning:
return "running"
case StateStopping:
return "stopping"
case StateStopped:
return "stopped"
case StateResetting:
return "resetting"
case StateError:
return "error"
default:
return "unknown"
}
}
// CanWrite 判断当前状态是否允许写入
func (s ProcessorState) CanWrite() bool {
// 允许在 Idle、Starting、Running 状态下写入
// 不允许在 Stopping、Stopped、Resetting、Error 状态下写入
return s == StateIdle || s == StateStarting || s == StateRunning
}
// CanQuery 判断当前状态是否允许查询
func (s ProcessorState) CanQuery() bool {
return s != StateResetting
}
// CanProcess 判断当前状态是否允许处理
func (s ProcessorState) CanProcess() bool {
return s == StateRunning
}
// ProcessorStatus 处理器状态信息
type ProcessorStatus struct {
State ProcessorState // 当前状态
LastUpdated time.Time // 最后更新时间
Error error // 错误信息(仅在 StateError 时有效)
}
// TopicProcessor 作为聚合器,持有所有核心组件并提供统一的访问接口
type TopicProcessor struct {
topic string
title string // 显示标题,用于 UI 展示
logPath string
logger *slog.Logger
// 核心组件(聚合)
writer *LogWriter // 写入器
index *RecordIndex // 索引管理器
query *RecordQuery // 查询器
cursor *LogCursor // 游标
tailer *LogTailer // 持续处理器
writer *LogWriter // 写入器
index *RecordIndex // 索引管理器
query *RecordQuery // 查询器
cursor *ProcessCursor // 游标
tailer *LogTailer // 持续处理器
// 配置和状态
handler RecordHandler
tailConfig *TailConfig
stats *TopicStats // 统计信息
eventBus *EventBus // 事件总线
stats *TopicStats // 统计信息
eventBus *EventBus // 事件总线
status ProcessorStatus // 处理器状态
// 并发控制
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running bool
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// TopicConfig topic 配置
type TopicConfig struct {
Title string // 显示标题,可选,默认为 topic 名称
Handler RecordHandler // 处理函数(必填)
TailConfig *TailConfig // tail 配置,可选
}
@@ -49,7 +109,7 @@ type TopicConfig struct {
func NewTopicProcessor(baseDir, topic string, logger *slog.Logger, config *TopicConfig) (*TopicProcessor, error) {
// 验证必填参数
if config == nil || config.Handler == nil {
return nil, fmt.Errorf("config and config.Handler are required")
return nil, NewValidationError("config", "config and config.Handler are required", ErrInvalidConfig)
}
ctx, cancel := context.WithCancel(context.Background())
@@ -71,16 +131,27 @@ func NewTopicProcessor(baseDir, topic string, logger *slog.Logger, config *Topic
logPath := filepath.Join(baseDir, topic+".log")
statsPath := filepath.Join(baseDir, topic+".stats")
// 设置 title如果未提供则使用 topic 名称
title := config.Title
if title == "" {
title = topic
}
tp := &TopicProcessor{
topic: topic,
title: title,
logPath: logPath,
logger: logger,
handler: config.Handler,
tailConfig: tailConfig,
stats: NewTopicStats(statsPath),
eventBus: NewEventBus(),
ctx: ctx,
cancel: cancel,
status: ProcessorStatus{
State: StateIdle,
LastUpdated: time.Now(),
},
ctx: ctx,
cancel: cancel,
}
// 初始化所有组件
@@ -110,7 +181,7 @@ func (tp *TopicProcessor) initializeComponents() error {
tp.writer = writer
// 3. 创建查询器(使用共享 index
query, err := NewRecordQuery(tp.logPath, tp.index)
query, err := NewRecordQuery(tp.logPath, tp.index, tp.writer)
if err != nil {
tp.writer.Close()
tp.index.Close()
@@ -118,8 +189,8 @@ func (tp *TopicProcessor) initializeComponents() error {
}
tp.query = query
// 4. 创建游标(使用共享 index
cursor, err := NewCursor(tp.logPath, tp.index)
// 4. 创建游标(使用共享 index 和 writer
cursor, err := NewCursor(tp.logPath, tp.index, tp.writer)
if err != nil {
tp.query.Close()
tp.writer.Close()
@@ -191,6 +262,18 @@ func (tp *TopicProcessor) createTailer() error {
// Write 写入日志(统一接口)
func (tp *TopicProcessor) Write(data []byte) (int64, error) {
// 检查状态
tp.mu.RLock()
state := tp.status.State
tp.mu.RUnlock()
if !state.CanWrite() {
if state == StateResetting {
return 0, ErrProcessorResetting
}
return 0, ErrNotRunning
}
offset, err := tp.writer.Append(data)
if err != nil {
tp.logger.Error("failed to append", "error", err)
@@ -228,30 +311,54 @@ func (tp *TopicProcessor) Start() error {
tp.mu.Lock()
defer tp.mu.Unlock()
if tp.running {
return fmt.Errorf("topic processor for %s is already running", tp.topic)
// 检查状态
if tp.status.State != StateIdle && tp.status.State != StateStopped {
return NewTopicError(tp.topic, "start", ErrInvalidState)
}
tp.logger.Debug("starting processor")
// 设置为启动中状态
tp.setState(StateStarting, nil)
// 重新创建 context如果之前被 cancel 了)
if tp.ctx.Err() != nil {
tp.ctx, tp.cancel = context.WithCancel(context.Background())
}
tp.running = true
// 启动定期保存统计信息的 goroutine
tp.wg.Add(1)
go func() {
defer tp.wg.Done()
ticker := time.NewTicker(tp.tailConfig.SaveInterval)
defer ticker.Stop()
for {
select {
case <-tp.ctx.Done():
return
case <-ticker.C:
if err := tp.stats.Save(); err != nil {
tp.logger.Error("failed to save stats", "error", err)
}
}
}
}()
// 如果 tailer 已创建,启动它
if tp.tailer != nil {
tp.logger.Debug("launching tailer goroutine")
tp.wg.Go(func() {
tp.logger.Debug("tailer goroutine started")
if err := tp.tailer.Start(tp.ctx); err != nil && err != context.Canceled {
tp.logger.Error("tailer error", "error", err)
}
tp.logger.Debug("tailer goroutine finished")
})
}
tp.logger.Debug("launching tailer goroutine")
tp.wg.Add(1)
go func() {
defer tp.wg.Done()
tp.logger.Debug("tailer goroutine started")
if err := tp.tailer.Start(tp.ctx); err != nil && err != context.Canceled {
tp.logger.Error("tailer error", "error", err)
}
tp.logger.Debug("tailer goroutine finished")
}()
// 设置为运行中状态
tp.setState(StateRunning, nil)
// 发布启动事件
tp.eventBus.Publish(&Event{
@@ -266,12 +373,14 @@ func (tp *TopicProcessor) Start() error {
// Stop 停止 tailer
func (tp *TopicProcessor) Stop() error {
tp.mu.Lock()
if !tp.running {
if tp.status.State != StateRunning {
tp.mu.Unlock()
return nil
}
tp.logger.Debug("stopping processor")
tp.running = false
// 设置为停止中状态
tp.setState(StateStopping, nil)
tp.cancel()
tp.mu.Unlock()
@@ -280,6 +389,11 @@ func (tp *TopicProcessor) Stop() error {
tp.logger.Debug("processor stopped")
// 设置为已停止状态
tp.mu.Lock()
tp.setState(StateStopped, nil)
tp.mu.Unlock()
// 发布停止事件
tp.eventBus.Publish(&Event{
Type: EventProcessorStop,
@@ -295,11 +409,43 @@ func (tp *TopicProcessor) Topic() string {
return tp.topic
}
// IsRunning 检查是否正在运行
func (tp *TopicProcessor) IsRunning() bool {
// Title 返回显示标题
func (tp *TopicProcessor) Title() string {
return tp.title
}
// GetState 获取当前状态
func (tp *TopicProcessor) GetState() ProcessorState {
tp.mu.RLock()
defer tp.mu.RUnlock()
return tp.running
return tp.status.State
}
// GetStatus 获取完整状态信息
func (tp *TopicProcessor) GetStatus() ProcessorStatus {
tp.mu.RLock()
defer tp.mu.RUnlock()
return tp.status
}
// setState 设置状态(需持有锁)
func (tp *TopicProcessor) setState(state ProcessorState, err error) {
tp.status.State = state
tp.status.LastUpdated = time.Now()
tp.status.Error = err
// 发布状态变更事件
tp.eventBus.Publish(&Event{
Type: EventStateChanged,
Topic: tp.topic,
Timestamp: time.Now(),
Error: err,
})
}
// IsRunning 检查是否正在运行
func (tp *TopicProcessor) IsRunning() bool {
return tp.GetState() == StateRunning
}
// UpdateTailConfig 动态更新 tail 配置
@@ -335,21 +481,19 @@ func (tp *TopicProcessor) GetStats() Stats {
}
// Query 获取共享的查询器
func (tp *TopicProcessor) Query() *RecordQuery {
return tp.query
}
func (tp *TopicProcessor) Query() (*RecordQuery, error) {
tp.mu.RLock()
defer tp.mu.RUnlock()
// QueryOldest 从指定索引开始查询记录(向前读取)
// startIndex: 查询起始索引
// count: 查询数量
// 返回的记录包含状态信息(基于 tailer 的窗口索引),按时间顺序(索引递增方向)
func (tp *TopicProcessor) QueryOldest(startIndex, count int) ([]*RecordWithStatus, error) {
// 查询记录
records, err := tp.query.QueryOldest(startIndex, count)
if err != nil {
return nil, err
if !tp.status.State.CanQuery() {
return nil, ErrProcessorResetting
}
return tp.query, nil
}
// addStatusToRecords 为记录添加状态信息(辅助方法)
func (tp *TopicProcessor) addStatusToRecords(records []*RecordWithIndex) []*RecordWithStatus {
// 获取窗口索引范围(用于状态判断)
var startIdx, endIdx int
tp.mu.RLock()
@@ -363,25 +507,17 @@ func (tp *TopicProcessor) QueryOldest(startIndex, count int) ([]*RecordWithStatu
results := make([]*RecordWithStatus, len(records))
for i, rec := range records {
results[i] = &RecordWithStatus{
Record: rec,
Status: GetRecordStatus(startIndex+i, startIdx, endIdx),
Record: rec.Record,
Index: rec.Index,
Status: GetRecordStatus(rec.Index, startIdx, endIdx),
}
}
return results, nil
return results
}
// QueryNewest 从指定索引开始向后查询记录(索引递减方向)
// endIndex: 查询结束索引(最新的记录)
// count: 查询数量
// 返回的记录包含状态信息(基于 tailer 的窗口索引),按时间倒序(最新在前)
func (tp *TopicProcessor) QueryNewest(endIndex, count int) ([]*RecordWithStatus, error) {
// 查询记录
records, err := tp.query.QueryNewest(endIndex, count)
if err != nil {
return nil, err
}
// addStatusToMetadata 为元数据添加状态信息
func (tp *TopicProcessor) addStatusToMetadata(metadata []*RecordMetadata) []*RecordMetadataWithStatus {
// 获取窗口索引范围(用于状态判断)
var startIdx, endIdx int
tp.mu.RLock()
@@ -391,16 +527,94 @@ func (tp *TopicProcessor) QueryNewest(endIndex, count int) ([]*RecordWithStatus,
}
tp.mu.RUnlock()
// 为每条记录添加状态倒序endIndex, endIndex-1, ...
results := make([]*RecordWithStatus, len(records))
for i, rec := range records {
results[i] = &RecordWithStatus{
Record: rec,
Status: GetRecordStatus(endIndex-i, startIdx, endIdx),
// 为每个元数据添加状态
results := make([]*RecordMetadataWithStatus, len(metadata))
for i, meta := range metadata {
results[i] = &RecordMetadataWithStatus{
Metadata: meta,
Status: GetRecordStatus(meta.Index, startIdx, endIdx),
}
}
return results, nil
return results
}
// QueryOldest 从参考索引向索引递减方向查询记录(查询更早的记录)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录包含索引和状态信息,按索引递增方向排序
// 例如QueryOldest(5, 3) 查询索引 2, 3, 4不包含 5
func (tp *TopicProcessor) QueryOldest(refIndex, count int) ([]*RecordWithStatus, error) {
records, err := tp.query.QueryOldest(refIndex, count)
if err != nil {
return nil, err
}
return tp.addStatusToRecords(records), nil
}
// QueryNewest 从参考索引向索引递增方向查询记录(查询更新的记录)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录包含索引和状态信息,按索引递增方向排序
// 例如QueryNewest(5, 3) 查询索引 6, 7, 8不包含 5
func (tp *TopicProcessor) QueryNewest(refIndex, count int) ([]*RecordWithStatus, error) {
records, err := tp.query.QueryNewest(refIndex, count)
if err != nil {
return nil, err
}
return tp.addStatusToRecords(records), nil
}
// QueryOldestMetadata 从参考索引向索引递减方向查询记录元数据(查询更早的记录,不读取完整数据)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录只包含元数据信息索引、UUID、数据大小按索引递增方向排序
// 例如QueryOldestMetadata(5, 3) 查询索引 2, 3, 4不包含 5
func (tp *TopicProcessor) QueryOldestMetadata(refIndex, count int) ([]*RecordMetadataWithStatus, error) {
metadata, err := tp.query.QueryOldestMetadata(refIndex, count)
if err != nil {
return nil, err
}
return tp.addStatusToMetadata(metadata), nil
}
// QueryNewestMetadata 从参考索引向索引递增方向查询记录元数据(查询更新的记录,不读取完整数据)
// refIndex: 参考索引位置
// count: 查询数量
// 返回的记录只包含元数据信息索引、UUID、数据大小按索引递增方向排序
// 例如QueryNewestMetadata(5, 3) 查询索引 6, 7, 8不包含 5
func (tp *TopicProcessor) QueryNewestMetadata(refIndex, count int) ([]*RecordMetadataWithStatus, error) {
metadata, err := tp.query.QueryNewestMetadata(refIndex, count)
if err != nil {
return nil, err
}
return tp.addStatusToMetadata(metadata), nil
}
// QueryByIndex 根据索引查询单条记录的完整数据
// index: 记录索引
// 返回完整的记录数据,包含状态信息
func (tp *TopicProcessor) QueryByIndex(index int) (*RecordWithStatus, error) {
record, err := tp.query.QueryByIndex(index)
if err != nil {
return nil, err
}
// 获取当前处理窗口位置
var startIdx, endIdx int
tp.mu.RLock()
if tp.tailer != nil {
startIdx = tp.tailer.GetStartIndex()
endIdx = tp.tailer.GetEndIndex()
}
tp.mu.RUnlock()
status := GetRecordStatus(index, startIdx, endIdx)
return &RecordWithStatus{
Record: record,
Index: index,
Status: status,
}, nil
}
// GetRecordCount 获取记录总数(统一接口)
@@ -408,16 +622,72 @@ func (tp *TopicProcessor) GetRecordCount() int {
return tp.index.Count()
}
// Cursor 创建一个新的游标实例(使用共享的 index
// 注意:每次调用都会创建新实例,调用者需要负责关闭
// Tailer 内部有自己的游标,不会与此冲突
func (tp *TopicProcessor) Cursor() (*LogCursor, error) {
return NewCursor(tp.logPath, tp.index)
// QueryFromProcessing 从当前处理窗口的开始位置向索引递增方向查询记录
// count: 查询数量
// 返回从处理窗口开始位置startIndex向后的记录包含状态信息
func (tp *TopicProcessor) QueryFromProcessing(count int) ([]*RecordWithStatus, error) {
// 获取当前处理窗口的开始位置
tp.mu.RLock()
var startIdx int
if tp.tailer != nil {
startIdx = tp.tailer.GetStartIndex()
}
tp.mu.RUnlock()
// 从 startIdx 开始向索引递增方向查询
// QueryNewest(refIndex, count) 查询从 refIndex+1 开始的记录
// 所以要从 startIdx 开始,应该调用 QueryNewest(startIdx - 1, count)
return tp.QueryNewest(startIdx-1, count)
}
// Index 获取索引管理器
func (tp *TopicProcessor) Index() *RecordIndex {
return tp.index
// QueryFromFirst 从第一条记录向索引递增方向查询
// count: 查询数量
// 返回从第一条记录(索引 0开始的记录包含状态信息
// 例如QueryFromFirst(3) 查询索引 0, 1, 2
func (tp *TopicProcessor) QueryFromFirst(count int) ([]*RecordWithStatus, error) {
// QueryNewest(-1, count) 会从索引 0 开始向后查询
return tp.QueryNewest(-1, count)
}
// QueryFromLast 从最后一条记录向索引递减方向查询
// count: 查询数量
// 返回从最后一条记录开始向前的记录,包含状态信息,按索引递增方向排序
// 例如:如果总共有 10 条记录QueryFromLast(3) 查询索引 7, 8, 9
func (tp *TopicProcessor) QueryFromLast(count int) ([]*RecordWithStatus, error) {
// 获取记录总数
totalCount := tp.index.Count()
// 如果没有记录,返回空数组
if totalCount == 0 {
return []*RecordWithStatus{}, nil
}
// QueryOldest(totalCount, count) 会从最后一条记录开始向前查询
// totalCount 是记录总数,有效索引是 0 到 totalCount-1
// 所以传入 totalCount 作为 refIndex会查询 totalCount-count 到 totalCount-1 的记录
return tp.QueryOldest(totalCount, count)
}
// QueryFromFirstMetadata 从第一条记录向索引递增方向查询元数据
// count: 查询数量
// 返回从第一条记录(索引 0开始的记录元数据包含状态信息
// 例如QueryFromFirstMetadata(3) 查询索引 0, 1, 2 的元数据
func (tp *TopicProcessor) QueryFromFirstMetadata(count int) ([]*RecordMetadataWithStatus, error) {
// QueryNewestMetadata(-1, count) 会从索引 0 开始向后查询
return tp.QueryNewestMetadata(-1, count)
}
// QueryFromLastMetadata 从最后一条记录向索引递减方向查询元数据
// count: 查询数量
// 返回最后 N 条记录的元数据(按索引递增方向排序),包含状态信息
// 例如QueryFromLastMetadata(3) 查询最后 3 条记录的元数据
func (tp *TopicProcessor) QueryFromLastMetadata(count int) ([]*RecordMetadataWithStatus, error) {
totalCount := tp.index.Count()
if totalCount == 0 {
return []*RecordMetadataWithStatus{}, nil
}
// QueryOldestMetadata(totalCount, count) 会从 totalCount 向前查询 count 条
return tp.QueryOldestMetadata(totalCount, count)
}
// GetProcessingIndex 获取当前处理索引(窗口开始索引)
@@ -459,72 +729,139 @@ func (tp *TopicProcessor) Unsubscribe(eventType EventType) {
tp.eventBus.Unsubscribe(eventType)
}
// CanReset 检查是否可以执行重置操作
// 返回 true 表示可以重置false 表示不能重置(正在重置中或有待处理的记录)
func (tp *TopicProcessor) CanReset() bool {
tp.mu.RLock()
defer tp.mu.RUnlock()
return tp.canResetLocked()
}
// canResetLocked 内部方法,检查是否可以重置(调用前必须已持有锁)
func (tp *TopicProcessor) canResetLocked() bool {
// 检查当前状态
if tp.status.State == StateResetting {
return false
}
// 检查是否有待处理的日志
processingIndex := 0
if tp.tailer != nil {
processingIndex = tp.tailer.GetStartIndex()
}
recordCount := 0
if tp.index != nil {
recordCount = tp.index.Count()
}
// 只有当所有记录都已处理完成时才能重置
return processingIndex >= recordCount
}
// Reset 清空 topic 的所有数据,包括日志文件、位置文件和统计文件
// 注意:必须在 Stop 之后调用
// 注意:Reset 不会停止 processor只有在没有待处理的日志时才能执行
// 重置完成后,如果之前是 Running 状态,会自动恢复到 Running 状态
func (tp *TopicProcessor) Reset() error {
tp.mu.Lock()
defer tp.mu.Unlock()
if tp.running {
return fmt.Errorf("cannot reset while processor is running, please stop first")
// 检查是否可以执行重置操作
if !tp.canResetLocked() {
// 提供详细的错误信息
currentState := tp.status.State
if currentState == StateResetting {
return nil
}
// 获取待处理记录信息
processingIndex := 0
if tp.tailer != nil {
processingIndex = tp.tailer.GetStartIndex()
}
recordCount := 0
if tp.index != nil {
recordCount = tp.index.Count()
}
return fmt.Errorf("cannot reset with pending records (%d/%d processed), wait for processing to complete", processingIndex, recordCount)
}
tp.logger.Debug("resetting processor")
// 记录之前是否在运行
currentState := tp.status.State
wasRunning := currentState == StateRunning
// 进入 Resetting 状态
tp.setState(StateResetting, nil)
tp.logger.Debug("entering resetting state", "wasRunning", wasRunning, "currentState", currentState)
// 如果之前在运行,停止 goroutines
if wasRunning && tp.cancel != nil {
tp.cancel()
// 释放锁以等待 goroutines 停止
tp.mu.Unlock()
tp.wg.Wait()
tp.mu.Lock()
}
var errs []error
// 关闭 writer如果还未关闭
if tp.writer != nil {
if err := tp.writer.Close(); err != nil {
tp.logger.Error("failed to close writer during reset", "error", err)
errs = append(errs, fmt.Errorf("close writer: %w", err))
}
tp.writer = nil
}
// 删除日志文件
if err := os.Remove(tp.logPath); err != nil && !os.IsNotExist(err) {
tp.logger.Error("failed to remove log file", "error", err)
errs = append(errs, fmt.Errorf("remove log file: %w", err))
}
// 删除位置文件
posFile := tp.logPath + ".pos"
if err := os.Remove(posFile); err != nil && !os.IsNotExist(err) {
tp.logger.Error("failed to remove position file", "error", err)
errs = append(errs, fmt.Errorf("remove position file: %w", err))
}
// 删除索引文件
indexFile := tp.logPath + ".idx"
if err := os.Remove(indexFile); err != nil && !os.IsNotExist(err) {
tp.logger.Error("failed to remove index file", "error", err)
errs = append(errs, fmt.Errorf("remove index file: %w", err))
}
// 关闭所有组件
if tp.query != nil {
tp.query.Close()
tp.query = nil
}
// 重置所有组件(保持引用关系不变
// 1. 重置索引(会删除索引文件并重新创建)
if tp.index != nil {
tp.index.Close()
tp.index = nil
if err := tp.index.Reset(); err != nil {
tp.logger.Error("failed to reset index", "error", err)
errs = append(errs, fmt.Errorf("reset index: %w", err))
}
}
// 重新初始化所有组件(已持有锁
// 这会重新创建 index, writer, query如果有 handler 也会创建 tailer
if err := tp.initializeComponents(); err != nil {
tp.logger.Error("failed to reinitialize components", "error", err)
errs = append(errs, fmt.Errorf("reinitialize components: %w", err))
// 2. 重置写入器(会删除日志文件并重新创建
if tp.writer != nil {
if err := tp.writer.Reset(); err != nil {
tp.logger.Error("failed to reset writer", "error", err)
errs = append(errs, fmt.Errorf("reset writer: %w", err))
}
}
// 重置统计信息
// 3. 重置游标(会删除位置文件并重置位置)
if tp.cursor != nil {
if err := tp.cursor.Reset(); err != nil {
tp.logger.Error("failed to reset cursor", "error", err)
errs = append(errs, fmt.Errorf("reset cursor: %w", err))
}
}
// 4. 重置查询器(重新打开日志文件)
if tp.query != nil {
if err := tp.query.Reset(); err != nil {
tp.logger.Error("failed to reset query", "error", err)
errs = append(errs, fmt.Errorf("reset query: %w", err))
}
}
// 5. 重置 tailer重置内部状态
if tp.tailer != nil {
if err := tp.tailer.Reset(); err != nil {
tp.logger.Error("failed to reset tailer", "error", err)
errs = append(errs, fmt.Errorf("reset tailer: %w", err))
}
}
// 6. 重置统计信息(会删除统计文件并重置所有计数器)
if tp.stats != nil {
tp.stats.Reset()
if err := tp.stats.Reset(); err != nil {
tp.logger.Error("failed to reset stats", "error", err)
errs = append(errs, fmt.Errorf("reset stats: %w", err))
}
}
tp.logger.Debug("processor reset completed")
// 如果有错误,设置为错误状态
if len(errs) > 0 {
tp.setState(StateError, errs[0])
return errs[0]
}
// 发布重置事件
tp.eventBus.Publish(&Event{
@@ -533,7 +870,56 @@ func (tp *TopicProcessor) Reset() error {
Timestamp: time.Now(),
})
// 如果有多个错误,返回第一个
// 如果之前在运行,恢复到 Running 状态
if wasRunning {
// 创建新的 context
tp.ctx, tp.cancel = context.WithCancel(context.Background())
// 启动 tailer goroutine如果有 handler
if tp.handler != nil && tp.tailer != nil {
tp.wg.Add(1)
go func() {
defer tp.wg.Done()
if err := tp.tailer.Start(tp.ctx); err != nil && err != context.Canceled {
tp.logger.Error("tailer error after reset", "error", err)
}
}()
}
// 启动统计保存 goroutine
tp.wg.Add(1)
go func() {
defer tp.wg.Done()
ticker := time.NewTicker(tp.tailConfig.SaveInterval)
defer ticker.Stop()
for {
select {
case <-tp.ctx.Done():
return
case <-ticker.C:
if tp.stats != nil {
if err := tp.stats.Save(); err != nil {
tp.logger.Error("failed to save stats", "error", err)
}
}
}
}
}()
tp.setState(StateRunning, nil)
tp.logger.Debug("processor reset completed, resumed to running state")
} else {
// 恢复到之前的状态(通常是 Idle 或 Stopped
if currentState == StateStopped {
tp.setState(StateStopped, nil)
} else {
tp.setState(StateIdle, nil)
}
tp.logger.Debug("processor reset completed", "newState", tp.status.State)
}
// 如果有错误,返回第一个
if len(errs) > 0 {
return errs[0]
}

568
ui/index.html Normal file
View File

@@ -0,0 +1,568 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Seqlog Dashboard</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
[v-cloak] { display: none; }
.log-entry {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
}
.log-data {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
}
.log-data:hover {
background-color: rgba(0, 0, 0, 0.02);
}
/* Dialog 样式 */
dialog {
border: none;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
padding: 0;
max-width: 56rem;
width: 90vw;
max-height: 80vh;
overflow: hidden;
}
dialog .dialog-container {
max-height: 80vh;
display: flex;
flex-direction: column;
}
dialog .dialog-content {
overflow-y: auto;
flex: 1;
min-height: 0;
}
dialog:not([open]) {
display: none !important;
}
dialog[open] {
display: block !important;
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
</style>
</head>
<body class="bg-gray-50">
<div id="app" v-cloak>
<!-- 头部 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h1 class="text-2xl font-bold text-gray-900">Seqlog Dashboard</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">实时日志查询系统</span>
</div>
</div>
</div>
</header>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="grid grid-cols-12 gap-6">
<!-- 侧边栏 -->
<div class="col-span-12 lg:col-span-3">
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Topics</h2>
<div v-if="topics.length === 0" class="text-gray-500 text-sm text-center py-4">
暂无 Topic
</div>
<div class="space-y-2">
<button
v-for="topicInfo in topics"
:key="topicInfo.topic"
@click="selectTopic(topicInfo.topic)"
:class="[
'w-full text-left px-4 py-2 rounded-md transition-colors',
selectedTopic === topicInfo.topic
? 'bg-blue-600 text-white'
: 'bg-gray-50 text-gray-700 hover:bg-gray-100'
]">
{{ topicInfo.title }}
</button>
</div>
</div>
<!-- 统计信息 -->
<div v-if="stats" class="bg-white rounded-lg shadow p-4 mt-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">统计信息</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">写入次数:</span>
<span class="font-medium">{{ stats.write_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">写入大小:</span>
<span class="font-medium">{{ formatBytes(stats.write_bytes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">已处理:</span>
<span class="font-medium">{{ stats.processed_count }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">处理大小:</span>
<span class="font-medium">{{ formatBytes(stats.processed_bytes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">错误次数:</span>
<span class="font-medium text-red-600">{{ stats.error_count }}</span>
</div>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="col-span-12 lg:col-span-9">
<div class="bg-white rounded-lg shadow">
<!-- 控制栏 -->
<div class="border-b px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-3">
<button
@click="loadLogs"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium">
刷新
</button>
<button
@click="queryFirst"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
最早
</button>
<button
@click="queryLast"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm">
最新
</button>
<div class="flex items-center space-x-2">
<label class="text-sm text-gray-600">显示:</label>
<input
v-model.number="queryCount"
type="number"
min="1"
max="100"
class="w-20 px-2 py-1 border rounded text-sm">
<span class="text-sm text-gray-600"></span>
</div>
</div>
<div v-if="selectedTopic" class="text-sm text-gray-500">
Topic: <span class="font-medium text-gray-900">{{ selectedTopic }}</span>
</div>
</div>
<!-- 日志列表 -->
<div class="p-4">
<div v-if="!selectedTopic" class="text-center py-12 text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="mt-2">请选择一个 Topic 开始查看日志</p>
</div>
<div v-else-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="logs.length === 0" class="text-center py-12 text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="mt-2">暂无日志记录</p>
</div>
<div v-else class="space-y-2 max-h-[600px] overflow-y-auto" ref="logContainer" @scroll="handleScroll">
<div
v-for="log in logs"
:key="log.index"
:class="[
'log-entry p-3 rounded-md border-l-4 transition-colors',
getLogClass(log.status)
]">
<div class="flex items-start justify-between mb-1">
<div class="flex items-center space-x-2">
<span class="text-xs font-mono text-gray-500">#{{ log.index }}</span>
<span :class="['px-2 py-0.5 rounded-full text-xs font-medium', getStatusClass(log.status)]">
{{ getStatusText(log.status) }}
</span>
<span class="text-xs text-gray-500">{{ formatBytes(log.dataSize || (log.data ? log.data.length : 0)) }}</span>
</div>
<span class="text-xs text-gray-400 font-mono">{{ formatUUID(log.uuid) }}</span>
</div>
<div class="log-data text-sm text-gray-800 break-all whitespace-pre-wrap" @click="showLogDetail(log)">
{{ log.dataPreview || log.data }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 日志详情对话框 -->
<dialog ref="logDialog">
<div class="dialog-container">
<template v-if="selectedLog">
<!-- 对话框头部 -->
<div class="flex items-center justify-between p-4 border-b flex-shrink-0">
<div class="flex items-center space-x-3">
<h3 class="text-lg font-semibold text-gray-900">日志详情</h3>
<span class="text-sm text-gray-500">#{{ selectedLog.index }}</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- 对话框内容 -->
<div class="dialog-content p-4">
<div class="space-y-3">
<div class="flex items-center space-x-4 text-sm">
<div class="flex items-center space-x-2">
<span class="text-gray-600">UUID:</span>
<span class="font-mono text-gray-900">{{ selectedLog.uuid }}</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-600">状态:</span>
<span :class="['px-2 py-0.5 rounded-full text-xs font-medium', getStatusClass(selectedLog.status)]">
{{ getStatusText(selectedLog.status) }}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-600">大小:</span>
<span class="font-medium text-gray-900">{{ formatBytes(selectedLog.dataSize || (selectedLog.data ? selectedLog.data.length : 0)) }}</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">日志内容</label>
<pre class="bg-gray-50 p-4 rounded-md text-sm font-mono text-gray-800 whitespace-pre-wrap break-all border border-gray-200">{{ selectedLog.data }}</pre>
</div>
</div>
</div>
<!-- 对话框底部 -->
<div class="p-4 border-t flex justify-end flex-shrink-0">
<button @click="closeModal" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors">
关闭
</button>
</div>
</template>
</div>
</dialog>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
topics: [],
selectedTopic: null,
logs: [],
stats: null,
loading: false,
queryCount: 20,
selectedLog: null,
loadingOlder: false, // 正在加载更早的记录
loadingNewer: false, // 正在加载更新的记录
scrollThreshold: 50, // 触发加载的阈值(像素)
loadingDetail: false // 正在加载详情
}
},
methods: {
async loadTopics() {
try {
const response = await fetch('/api/topics');
this.topics = await response.json();
} catch (error) {
console.error('加载 topics 失败:', error);
}
},
async selectTopic(topic) {
this.selectedTopic = topic;
await this.loadStats();
await this.loadLogs();
},
async loadStats() {
if (!this.selectedTopic) return;
try {
const response = await fetch(`/api/stats?topic=${this.selectedTopic}`);
this.stats = await response.json();
} catch (error) {
console.error('加载统计信息失败:', error);
}
},
async loadLogs() {
if (!this.selectedTopic) return;
this.loading = true;
try {
const response = await fetch(
`/api/logs?topic=${this.selectedTopic}&count=${this.queryCount}`
);
const data = await response.json();
this.logs = data.records || [];
if (data.stats) {
this.stats = data.stats;
}
} catch (error) {
console.error('加载日志失败:', error);
} finally {
this.loading = false;
}
},
async queryFirst() {
if (!this.selectedTopic) return;
this.loading = true;
try {
const response = await fetch(
`/api/logs/first?topic=${this.selectedTopic}&count=${this.queryCount}`
);
const data = await response.json();
this.logs = data.records || [];
} catch (error) {
console.error('查询失败:', error);
} finally {
this.loading = false;
}
},
async queryLast() {
if (!this.selectedTopic) return;
this.loading = true;
try {
const response = await fetch(
`/api/logs/last?topic=${this.selectedTopic}&count=${this.queryCount}`
);
const data = await response.json();
this.logs = data.records || [];
} catch (error) {
console.error('查询失败:', error);
} finally {
this.loading = false;
}
},
getLogClass(status) {
const classes = {
'bg-gray-50 border-gray-300': status === 'pending',
'bg-yellow-50 border-yellow-400': status === 'processing',
'bg-green-50 border-green-400': status === 'processed'
};
return classes[status] || 'bg-gray-50 border-gray-300';
},
getStatusClass(status) {
const classes = {
'bg-gray-200 text-gray-700': status === 'pending',
'bg-yellow-200 text-yellow-800': status === 'processing',
'bg-green-200 text-green-800': status === 'processed'
};
return classes[status] || 'bg-gray-200 text-gray-700';
},
getStatusText(status) {
const texts = {
'pending': '待处理',
'processing': '处理中',
'processed': '已处理'
};
return texts[status] || '未知';
},
formatUUID(uuid) {
// 只显示前 8 位
return uuid ? uuid.substring(0, 8) : '';
},
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
async showLogDetail(log) {
// 如果已经有完整数据,直接显示
if (log.data && !log.dataPreview) {
this.selectedLog = log;
this.$refs.logDialog.showModal();
return;
}
// 否则先显示弹窗,然后加载完整数据
this.selectedLog = { ...log, data: '加载中...' };
this.$refs.logDialog.showModal();
this.loadingDetail = true;
try {
const response = await fetch(
`/api/logs/record?topic=${this.selectedTopic}&index=${log.index}`
);
const fullRecord = await response.json();
// 更新 selectedLog 为完整数据
this.selectedLog = fullRecord;
} catch (error) {
console.error('加载日志详情失败:', error);
this.selectedLog.data = '加载失败';
} finally {
this.loadingDetail = false;
}
},
closeModal() {
this.$refs.logDialog.close();
this.selectedLog = null;
},
// 处理滚动事件
handleScroll(event) {
const container = event.target;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
// 触顶:加载更早的记录
if (scrollTop < this.scrollThreshold && !this.loadingOlder && this.logs.length > 0) {
this.loadOlder();
}
// 触底:加载更新的记录
if (scrollHeight - scrollTop - clientHeight < this.scrollThreshold && !this.loadingNewer && this.logs.length > 0) {
this.loadNewer();
}
},
// 加载更早的记录(向前)
async loadOlder() {
if (!this.selectedTopic || this.logs.length === 0 || this.loadingOlder) return;
// 获取当前最小索引
const minIndex = Math.min(...this.logs.map(log => log.index));
if (minIndex === 0) return; // 已经到达最早
this.loadingOlder = true;
const container = this.$refs.logContainer;
const oldScrollHeight = container.scrollHeight;
try {
const response = await fetch(
`/api/logs/older?topic=${this.selectedTopic}&refIndex=${minIndex}&count=${this.queryCount}`
);
const data = await response.json();
const newRecords = data.records || [];
if (newRecords.length > 0) {
// 将新记录添加到数组开头
this.logs = [...newRecords, ...this.logs];
// 保持滚动位置
this.$nextTick(() => {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - oldScrollHeight;
});
}
} catch (error) {
console.error('加载更早记录失败:', error);
} finally {
this.loadingOlder = false;
}
},
// 加载更新的记录(向后)
async loadNewer() {
if (!this.selectedTopic || this.logs.length === 0 || this.loadingNewer) return;
// 获取当前最大索引
const maxIndex = Math.max(...this.logs.map(log => log.index));
this.loadingNewer = true;
try {
const response = await fetch(
`/api/logs/newer?topic=${this.selectedTopic}&refIndex=${maxIndex}&count=${this.queryCount}`
);
const data = await response.json();
const newRecords = data.records || [];
if (newRecords.length > 0) {
// 将新记录添加到数组末尾
this.logs = [...this.logs, ...newRecords];
}
} catch (error) {
console.error('加载更新记录失败:', error);
} finally {
this.loadingNewer = false;
}
}
},
mounted() {
this.loadTopics();
// 每 3 秒自动刷新统计信息
setInterval(() => {
if (this.selectedTopic) {
this.loadStats();
}
}, 3000);
// 监听 dialog 关闭事件ESC 键)
this.$refs.logDialog.addEventListener('close', () => {
this.selectedLog = null;
});
// 点击弹窗外部关闭弹窗
this.$refs.logDialog.addEventListener('click', (event) => {
const rect = this.$refs.logDialog.getBoundingClientRect();
const isInDialog = (
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width
);
if (!isInDialog) {
this.closeModal();
}
});
}
}).mount('#app');
</script>
</body>
</html>

451
webui.go Normal file
View File

@@ -0,0 +1,451 @@
package seqlog
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"strconv"
"github.com/google/uuid"
)
//go:embed ui/*
var uiFS embed.FS
// APIRecord API 响应的记录结构(包含完整数据)
type APIRecord struct {
Index int `json:"index"`
UUID uuid.UUID `json:"uuid"`
Data string `json:"data"` // 转换为字符串
Status string `json:"status"` // 状态文本
}
// APIRecordMetadata API 响应的记录元数据结构(不包含完整数据)
type APIRecordMetadata struct {
Index int `json:"index"`
UUID uuid.UUID `json:"uuid"`
DataSize uint32 `json:"dataSize"` // 数据大小(字节)
DataPreview string `json:"dataPreview"` // 数据预览
Status string `json:"status"` // 状态文本
Full bool `json:"full"` // 是否完整
}
// toAPIRecord 将 RecordWithStatus 转换为 APIRecord
func toAPIRecord(r *RecordWithStatus) APIRecord {
statusText := "pending"
switch r.Status {
case StatusPending:
statusText = "pending"
case StatusProcessing:
statusText = "processing"
case StatusProcessed:
statusText = "processed"
}
return APIRecord{
Index: r.Index,
UUID: r.Record.UUID,
Data: string(r.Record.Data), // 转换为字符串
Status: statusText,
}
}
// toAPIRecords 批量转换
func toAPIRecords(records []*RecordWithStatus) []APIRecord {
result := make([]APIRecord, len(records))
for i, r := range records {
result[i] = toAPIRecord(r)
}
return result
}
// toAPIRecordMetadata 将 RecordMetadataWithStatus 转换为 APIRecordMetadata
func toAPIRecordMetadata(r *RecordMetadataWithStatus) APIRecordMetadata {
statusText := "pending"
switch r.Status {
case StatusPending:
statusText = "pending"
case StatusProcessing:
statusText = "processing"
case StatusProcessed:
statusText = "processed"
}
return APIRecordMetadata{
Index: r.Metadata.Index,
UUID: r.Metadata.UUID,
DataSize: r.Metadata.DataSize,
DataPreview: r.Metadata.DataPreview,
Status: statusText,
Full: r.Metadata.Full,
}
}
// toAPIRecordMetadatas 批量转换
func toAPIRecordMetadatas(metadata []*RecordMetadataWithStatus) []APIRecordMetadata {
result := make([]APIRecordMetadata, len(metadata))
for i, m := range metadata {
result[i] = toAPIRecordMetadata(m)
}
return result
}
// RegisterWebUIRoutes 将 Web UI 路由注册到指定的 ServeMux
// 这允许你将 Web UI 集成到现有的 HTTP 服务器或其他框架中
func (hub *LogHub) RegisterWebUIRoutes(mux *http.ServeMux) error {
// 提供静态文件
uiContent, err := fs.Sub(uiFS, "ui")
if err != nil {
return err
}
mux.Handle("/", http.FileServer(http.FS(uiContent)))
// API 端点
mux.HandleFunc("/api/topics", hub.handleTopics)
mux.HandleFunc("/api/logs", hub.handleLogs)
mux.HandleFunc("/api/logs/first", hub.handleLogsFirst)
mux.HandleFunc("/api/logs/last", hub.handleLogsLast)
mux.HandleFunc("/api/logs/older", hub.handleLogsOlder) // 返回元数据
mux.HandleFunc("/api/logs/newer", hub.handleLogsNewer) // 返回元数据
mux.HandleFunc("/api/logs/record", hub.handleLogsByIndex) // 根据索引查询完整数据
mux.HandleFunc("/api/stats", hub.handleStats)
return nil
}
// ServeUI 启动 Web UI 服务器
// 这会创建一个新的 HTTP 服务器并监听指定地址
func (hub *LogHub) ServeUI(addr string) error {
mux := http.NewServeMux()
if err := hub.RegisterWebUIRoutes(mux); err != nil {
return err
}
return http.ListenAndServe(addr, mux)
}
// TopicInfo topic 信息
type TopicInfo struct {
Topic string `json:"topic"` // topic 名称
Title string `json:"title"` // 显示标题
}
// handleTopics 返回所有 topic 列表及其标题
func (hub *LogHub) handleTopics(w http.ResponseWriter, r *http.Request) {
hub.mu.RLock()
topics := make([]TopicInfo, 0, len(hub.processors))
for topic, processor := range hub.processors {
topics = append(topics, TopicInfo{
Topic: topic,
Title: processor.Title(),
})
}
hub.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(topics)
}
// handleLogs 查询最新的 N 条日志(默认行为,只返回元数据)
func (hub *LogHub) handleLogs(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
count := 20
if countStr := r.URL.Query().Get("count"); countStr != "" {
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
count = c
}
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
// 查询最新的 N 条记录元数据(从最后一条记录开始向前查询)
metadata, err := processor.QueryFromLastMetadata(count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 获取统计信息
stats := processor.GetStats()
// 转换为 API 格式
apiMetadata := toAPIRecordMetadatas(metadata)
response := map[string]interface{}{
"records": apiMetadata,
"stats": stats,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleLogsFirst 查询从第一条记录开始的 N 条日志(只返回元数据)
func (hub *LogHub) handleLogsFirst(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
count := 20
if countStr := r.URL.Query().Get("count"); countStr != "" {
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
count = c
}
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
// 从第一条记录开始查询元数据(索引 0 开始向后)
metadata, err := processor.QueryFromFirstMetadata(count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 转换为 API 格式
apiMetadata := toAPIRecordMetadatas(metadata)
response := map[string]interface{}{
"records": apiMetadata,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleLogsLast 查询最后的 N 条日志(只返回元数据)
func (hub *LogHub) handleLogsLast(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
count := 20
if countStr := r.URL.Query().Get("count"); countStr != "" {
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
count = c
}
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
// 查询最后的 N 条记录元数据(从最后一条向前查询)
metadata, err := processor.QueryFromLastMetadata(count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 转换为 API 格式
apiMetadata := toAPIRecordMetadatas(metadata)
response := map[string]any{
"records": apiMetadata,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleStats 返回指定 topic 的统计信息
func (hub *LogHub) handleStats(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
stats := processor.GetStats()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// handleLogsOlder 从参考索引向前查询更早的日志(无限滚动支持,只返回元数据)
func (hub *LogHub) handleLogsOlder(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
// 获取参考索引
refIndexStr := r.URL.Query().Get("refIndex")
if refIndexStr == "" {
http.Error(w, "refIndex parameter required", http.StatusBadRequest)
return
}
refIndex, err := strconv.Atoi(refIndexStr)
if err != nil {
http.Error(w, "invalid refIndex", http.StatusBadRequest)
return
}
count := 20
if countStr := r.URL.Query().Get("count"); countStr != "" {
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
count = c
}
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
// 从 refIndex 向前查询更早的记录元数据
metadata, err := processor.QueryOldestMetadata(refIndex, count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 转换为 API 格式
apiMetadata := toAPIRecordMetadatas(metadata)
response := map[string]any{
"records": apiMetadata,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleLogsNewer 从参考索引向后查询更新的日志(无限滚动支持,只返回元数据)
func (hub *LogHub) handleLogsNewer(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
// 获取参考索引
refIndexStr := r.URL.Query().Get("refIndex")
if refIndexStr == "" {
http.Error(w, "refIndex parameter required", http.StatusBadRequest)
return
}
refIndex, err := strconv.Atoi(refIndexStr)
if err != nil {
http.Error(w, "invalid refIndex", http.StatusBadRequest)
return
}
count := 20
if countStr := r.URL.Query().Get("count"); countStr != "" {
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
count = c
}
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
// 从 refIndex 向后查询更新的记录元数据
metadata, err := processor.QueryNewestMetadata(refIndex, count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 转换为 API 格式
apiMetadata := toAPIRecordMetadatas(metadata)
response := map[string]any{
"records": apiMetadata,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleLogsByIndex 根据索引查询单条记录的完整数据
func (hub *LogHub) handleLogsByIndex(w http.ResponseWriter, r *http.Request) {
topic := r.URL.Query().Get("topic")
if topic == "" {
http.Error(w, "topic parameter required", http.StatusBadRequest)
return
}
// 获取索引
indexStr := r.URL.Query().Get("index")
if indexStr == "" {
http.Error(w, "index parameter required", http.StatusBadRequest)
return
}
index, err := strconv.Atoi(indexStr)
if err != nil {
http.Error(w, "invalid index", http.StatusBadRequest)
return
}
hub.mu.RLock()
processor, exists := hub.processors[topic]
hub.mu.RUnlock()
if !exists {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
// 根据索引查询完整记录
record, err := processor.QueryByIndex(index)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 转换为 API 格式
apiRecord := toAPIRecord(record)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(apiRecord)
}

View File

@@ -4,16 +4,20 @@ import (
"encoding/binary"
"hash/crc32"
"os"
"sync"
"github.com/google/uuid"
)
// LogWriter 日志写入器
type LogWriter struct {
fd *os.File
off int64 // 当前写入偏移
wbuf []byte // 8 MiB 复用
index *RecordIndex // 索引管理器(可选)
path string // 日志文件路径
fd *os.File
off int64 // 当前写入偏移
dirtyOff int64 // 最后一次写入偏移
wbuf []byte // 8 MiB 复用
index *RecordIndex // 索引管理器(可选)
mu sync.RWMutex // 保护 off 字段
}
// NewLogWriter 创建一个新的日志写入器
@@ -30,10 +34,12 @@ func NewLogWriter(path string, index *RecordIndex) (*LogWriter, error) {
off, _ := fd.Seek(0, 2) // 跳到尾部
w := &LogWriter{
fd: fd,
off: off,
wbuf: make([]byte, 0, 8<<20),
index: index,
path: path,
fd: fd,
off: off,
dirtyOff: -1,
wbuf: make([]byte, 0, 8<<20),
index: index,
}
return w, nil
@@ -41,15 +47,24 @@ func NewLogWriter(path string, index *RecordIndex) (*LogWriter, error) {
// Append 追加一条日志记录,返回该记录的偏移量
func (w *LogWriter) Append(data []byte) (int64, error) {
w.mu.Lock()
defer w.mu.Unlock()
// 记录当前偏移(返回给调用者,用于索引)
offset := w.off
w.dirtyOff = offset
defer func() {
w.dirtyOff = -1
}()
// 生成 UUID v4
id := uuid.New()
// 编码:[4B len][4B CRC][16B UUID][data]
// 编码:[4B len][8B offset][4B CRC][16B UUID][data]
buf := w.wbuf[:0]
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(data)))
buf = binary.LittleEndian.AppendUint64(buf, uint64(offset))
buf = binary.LittleEndian.AppendUint32(buf, crc32.ChecksumIEEE(data))
buf = append(buf, id[:]...)
buf = append(buf, data...)
@@ -74,6 +89,19 @@ func (w *LogWriter) Append(data []byte) (int64, error) {
return offset, nil
}
// GetWriteOffset 获取当前写入偏移量(线程安全)
func (w *LogWriter) GetWriteOffset() int64 {
w.mu.RLock()
defer w.mu.RUnlock()
return w.off
}
func (w *LogWriter) GetDirtyOffset() int64 {
w.mu.RLock()
defer w.mu.RUnlock()
return w.dirtyOff
}
// Close 关闭写入器
// 注意:不关闭 index因为 index 是外部管理的共享资源
func (w *LogWriter) Close() error {
@@ -82,3 +110,36 @@ func (w *LogWriter) Close() error {
}
return w.fd.Close()
}
// Reset 重置写入器,删除日志文件并重新创建
// 保持 index 和 wbuf 引用不变
func (w *LogWriter) Reset() error {
w.mu.Lock()
defer w.mu.Unlock()
// 关闭当前文件句柄
if w.fd != nil {
if err := w.fd.Close(); err != nil {
return err
}
w.fd = nil
}
// 删除日志文件
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return err
}
// 重新创建文件
fd, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
// 重置状态
w.fd = fd
w.off = 0
w.dirtyOff = -1
return nil
}