Smail/backend/services/runtimelog/range.go
smail bb4ac4d5f5
Some checks are pending
Security Checks / security (push) Waiting to run
过滤无价值日志并实现最近24小时倒序分页查询
2026-09-09 01:06:45 +08:00

190 lines
4.6 KiB
Go

package runtimelog
import (
"container/heap"
"context"
"errors"
"sort"
"time"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
)
type Query struct {
File string
From time.Time
To time.Time
Offset int
Limit int
}
type lineRef struct {
at time.Time
file int
position int
bytes int
}
// 索引只保存时间与行位置,不把历史日志全文装入内存。
type recentLines []lineRef
func (h recentLines) Len() int { return len(h) }
func (h recentLines) Less(i, j int) bool {
if !h[i].at.Equal(h[j].at) {
return h[i].at.Before(h[j].at)
}
if h[i].file != h[j].file {
return h[i].file > h[j].file
}
return h[i].position < h[j].position
}
func (h recentLines) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *recentLines) Push(value any) { *h = append(*h, value.(lineRef)) }
func (h *recentLines) Pop() any {
last := len(*h) - 1
value := (*h)[last]
*h = (*h)[:last]
return value
}
// Query 跨日流式扫描并按真实时间倒序,兼容压缩文件和同一文件中的乱序写入。
// 额外内存由最大偏移限制的行索引和两兆响应组成,不随日志文件体积增长。
func (w *Writer) Query(ctx context.Context, query Query) (Page, error) {
if query.File != "" {
if _, ok := fileDay(query.File); !ok {
return Page{}, ErrInvalid
}
}
if query.Offset < 0 || query.Offset > 100000 || query.Limit < 1 || query.Limit > 500 {
return Page{}, ErrInvalid
}
if query.To.IsZero() {
query.To = w.now()
}
if query.From.IsZero() {
query.From = query.To.Add(-24 * time.Hour)
}
if query.From.After(query.To) {
return Page{}, ErrInvalid
}
query.From = maxTime(query.From, logpolicy.Cutoff(w.now()))
// 等待归档时也响应取消,避免查询超时后继续占用 HTTP 工作协程。
for !w.archiveMu.TryLock() {
select {
case <-ctx.Done():
return Page{}, ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
defer w.archiveMu.Unlock()
if err := ctx.Err(); err != nil {
return Page{}, err
}
// 在短写锁内冻结文件长度;两次扫描不会读到新追加的半条记录。
w.mu.Lock()
files, err := w.files()
w.mu.Unlock()
if err != nil {
return Page{}, err
}
page := Page{Lines: make([]string, 0, query.Limit)}
if query.From.After(query.To) {
return page, nil
}
capacity := query.Offset + query.Limit + 1
refs := make(recentLines, 0, capacity)
for index, file := range files {
if query.File != "" && query.File != file.Name {
continue
}
day, _ := fileDay(file.Name)
if day.After(query.To) || !day.Add(24*time.Hour).After(query.From) {
continue
}
position := 0
err := w.scanSize(ctx, file.Name, file.Bytes, func(line []byte, at time.Time) error {
position++
if !at.UTC().Truncate(24 * time.Hour).Equal(day) {
return ErrInvalid
}
if at.Before(query.From) || at.After(query.To) {
return nil
}
visible, keep := presentLine(string(line))
if !keep {
return nil
}
candidate := lineRef{at: at, file: index, position: position, bytes: len(visible)}
if len(refs) < capacity {
heap.Push(&refs, candidate)
} else if (recentLines{refs[0], candidate}).Less(0, 1) {
refs[0] = candidate
heap.Fix(&refs, 0)
}
return nil
})
if err != nil {
return Page{}, err
}
}
sort.Sort(sort.Reverse(refs))
if query.Offset >= len(refs) {
return page, nil
}
refs = refs[query.Offset:]
page.HasMore = len(refs) > query.Limit
refs = refs[:min(len(refs), query.Limit)]
bytes := 0
for index, ref := range refs {
if bytes+ref.bytes > 2*1024*1024 {
refs = refs[:index]
page.HasMore = true
break
}
bytes += ref.bytes
}
page.Lines = make([]string, len(refs))
selected := make(map[int]map[int]int)
for index, ref := range refs {
if selected[ref.file] == nil {
selected[ref.file] = make(map[int]int)
}
selected[ref.file][ref.position] = index
}
finished := errors.New("本页内容读取完成")
for fileIndex, positions := range selected {
position, remaining := 0, len(positions)
file := files[fileIndex]
err := w.scanSize(ctx, file.Name, file.Bytes, func(line []byte, at time.Time) error {
position++
index, ok := positions[position]
if !ok {
return nil
}
if !at.Equal(refs[index].at) || len(line) != refs[index].bytes {
return ErrInvalid
}
page.Lines[index] = string(line)
remaining--
if remaining == 0 {
return finished
}
return nil
})
if err != nil && !errors.Is(err, finished) {
return Page{}, err
}
if remaining != 0 {
return Page{}, ErrInvalid
}
}
return page, nil
}
func maxTime(left, right time.Time) time.Time {
if left.After(right) {
return left
}
return right
}