Files
magpie/internal/usage/log_index.go
T
Snowyy 3d8c6f24c5 usage: capture Windows change time for cached logs (#594)
* usage: capture Windows change time for cached logs

* ci: show native Windows usage test outcomes
2026-10-03 01:15:29 +08:00

212 lines
5.9 KiB
Go

package usage
// The JSONL file remains the ledger. This disposable index keeps immutable,
// compressed blocks of its metadata; appending copies at most the last block.
// Readers decode only blocks which can overlap their time window.
import (
"bufio"
"encoding/json"
"io"
"maps"
"os"
"slices"
"sync"
"time"
)
const logBlockRows = 512
// Raw and priced chunks share the request cache budget. Oversized histories
// remain queryable but their raw blocks are rebuilt instead of retained.
type logSnapshot struct {
bytes int64
uncached bool
path string
info os.FileInfo
off int64
hash string
version uint64
blocks []*rowChunk
first time.Time
keyProviders map[string]bool
}
var logIndex struct {
sync.Mutex
snapshot *logSnapshot
version uint64
}
// File writes must never wait for a historical index rebuild.
var logAppends struct {
sync.Mutex
path string
base, last os.FileInfo
}
func readLogSnapshot() *logSnapshot { return logSnapshotFor(false) }
// Metadata-only callers can use cached results without rebuilding a history
// whose blocks do not fit the cache budget.
func logSnapshotFor(metadataOnly bool) *logSnapshot {
logIndex.Lock()
defer logIndex.Unlock()
path := Path()
info, err := statLogFile(path)
old := logIndex.snapshot
unchanged := old != nil && old.path == path && (err != nil && old.info == nil || err == nil && sameLogInfo(old.info, info))
if unchanged && info != nil && logChangeStamp(info) == "" {
unchanged = old.hash != "" && recordHash(path, info.Size()) == old.hash
}
if unchanged && (!old.uncached || metadataOnly) {
return old
}
if !unchanged {
logIndex.version++
}
next := &logSnapshot{path: path, info: info, version: logIndex.version, keyProviders: map[string]bool{}}
if err != nil {
logIndex.snapshot = next
return next
}
logAppends.Lock()
notePath, noteBase, noteLast := logAppends.path, logAppends.base, logAppends.last
logAppends.Unlock()
trusted := logChangeStamp(info) != "" && notePath == path && sameLogInfo(oldInfo(old), noteBase) && sameLogInfo(info, noteLast)
continued := old != nil && old.path == path && old.info != nil && sameLogFile(old.info, info) && info.Size() > old.info.Size() && (trusted || old.hash != "" && recordHash(path, old.info.Size()) == old.hash)
if continued && !old.uncached {
next.off, next.first = old.off, old.first
next.blocks = slices.Clone(old.blocks)
next.keyProviders = maps.Clone(old.keyProviders)
}
f, err := os.Open(path)
if err != nil {
return next
} // never publish an incomplete read as current
defer f.Close()
if _, err = f.Seek(next.off, 0); err != nil {
return next
}
var tail *rowChunk
if n := len(next.blocks); n > 0 && next.blocks[n-1].Count < logBlockRows {
tail = next.blocks[n-1].writable()
next.blocks = next.blocks[:n-1]
}
rd := bufio.NewReaderSize(io.LimitReader(f, info.Size()-next.off), 64<<10)
for next.off < info.Size() {
b, e := rd.ReadBytes('\n')
if e != nil {
break
} // a partial final line is read again after it completes
next.off += int64(len(b))
var r Record
if json.Unmarshal(b, &r) != nil {
continue
}
if tail == nil {
tail = &rowChunk{}
}
tail.add(Row{Record: r}, "", next.off, false)
if !r.IsRejected() && (next.first.IsZero() || r.Time.Before(next.first)) {
next.first = r.Time
}
if r.ProviderKeyID != "" {
next.keyProviders[r.Provider] = true
}
if len(tail.Rows) == logBlockRows {
next.blocks = append(next.blocks, tail.freeze())
tail = nil
}
}
if tail != nil {
next.blocks = append(next.blocks, tail.freeze())
}
if !trusted || logChangeStamp(info) == "" {
next.hash = recordHash(path, info.Size())
}
logAppends.Lock()
// Preserve writes which arrived while this immutable snapshot was built.
if logAppends.path == path && (sameLogInfo(logAppends.last, info) || sameLogInfo(logAppends.base, info) || notePath == path && sameLogInfo(noteLast, info) && sameLogInfo(noteBase, logAppends.base)) {
logAppends.base = info
} else if logAppends.last == nil {
logAppends.path, logAppends.base, logAppends.last = path, info, info
}
logAppends.Unlock()
for _, c := range next.blocks {
next.bytes += c.Bytes
}
if next.bytes > requestCacheBytes {
next.uncached = true
kept := *next
kept.blocks = nil
logIndex.snapshot = &kept
} else {
logIndex.snapshot = next
}
return next
}
func (s *logSnapshot) visit(since time.Time, fn func(Record)) {
for _, packed := range s.blocks {
if !since.IsZero() && packed.Latest.Before(since) {
continue
}
c := packed.unpack()
if c == nil {
continue
}
for i, p := range c.Rows {
if !p.Time.Before(since) {
fn(c.row(i).Record)
}
}
}
}
func (c *rowChunk) writable() *rowChunk {
d := c.unpack()
if d == nil {
return &rowChunk{}
}
next := *d
next.Rows, next.Strings = slices.Clone(d.Rows), slices.Clone(d.Strings)
next.dict = make(map[string]uint32, len(next.Strings))
for i, s := range next.Strings {
next.dict[s] = uint32(i)
}
next.Bytes += int64(32 * len(next.Strings))
return &next
}
func (c *rowChunk) freeze() *rowChunk {
if c.dict != nil {
c.dict = nil
c.Bytes -= int64(32 * len(c.Strings))
}
return c.pack()
}
func oldInfo(s *logSnapshot) os.FileInfo {
if s == nil {
return nil
}
return s.info
}
func sameLogInfo(a, b os.FileInfo) bool {
return a != nil && b != nil && sameLogFile(a, b) && a.Size() == b.Size() && a.ModTime().Equal(b.ModTime()) && logChangeStamp(a) == logChangeStamp(b)
}
// Append records the exact file states around its successful write. Contiguous
// writes by this process need no historical prefix scan; external edits still
// go through the fingerprint/rebuild path.
func noteLogAppend(path string, before, after os.FileInfo) {
logAppends.Lock()
defer logAppends.Unlock()
if logAppends.path != path || !sameLogInfo(before, logAppends.last) {
logAppends.path, logAppends.base = path, before
}
logAppends.last = after
}