194 lines
5.0 KiB
Go
194 lines
5.0 KiB
Go
package runtimelog
|
||
|
||
import (
|
||
"container/heap"
|
||
"context"
|
||
"errors"
|
||
"sort"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Smail/utils/logpolicy"
|
||
)
|
||
|
||
type Query struct {
|
||
// nil 表示完整管理视图;非 nil 时先按可信归属过滤并脱敏,再进行分页。
|
||
// 映射值为 true 表示本人;false 表示代管普通用户,事件发生时也必须是普通用户。
|
||
AllowedActors map[string]bool
|
||
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 := presentScopedLine(string(line), query.AllowedActors)
|
||
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
|
||
}
|
||
visible, keep := presentScopedLine(string(line), query.AllowedActors)
|
||
if !keep || !at.Equal(refs[index].at) || len(visible) != refs[index].bytes {
|
||
return ErrInvalid
|
||
}
|
||
page.Lines[index] = visible
|
||
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
|
||
}
|