Files
yetone 439cfc4ac4 usage: the parse is the log fingerprint, dropping the build's full re-read before it (#1357)
What changed. A usage-log snapshot's fingerprint is now produced by the
parse itself: each complete line read is fed into a SHA-256. When a build
continues the old snapshot's blocks, it continues the old snapshot's digest
too (crypto/sha256's state, kept on the snapshot as `digest`); otherwise the
digest starts fresh from byte 0 with the parse. The build's before-read
logRecordHash over the whole file is gone. The fingerprint covers the bytes
actually parsed (`off`), not info.Size(), so a half line another writer is
in the middle of appending is no longer counted as read; the unchanged
check, the prefix check and the after-read check all hash `off` bytes.

What stays, as #1328's tests require (ggbdpq's analysis on #1357): the
unsettled unchanged check, the prefix re-verification on a non-trusted
continuation, and the after-read check, which now compares the parse's own
digest against the file as it is after the read. A same-tick rewrite is
still caught at the build, not at a later read. On the trusted, settled
path nothing is re-read, as before; that snapshot now also carries a
fingerprint (the continued digest), so a later non-trusted append can
verify its prefix instead of rebuilding from byte 0.

Test changed: TestUsageRewriteBeforeFingerprintIsNotCached counted
logRecordHash calls and rewrote the file at call 1 (after the before-read
hash, before the parse) or call 2 (after the read). With the parse being
the fingerprint there is no call 1 any more, so the hash=2 case could no
longer fire. Its guarantee still holds and is still tested at both
boundaries: "before-read" rewrites at the build's first change-time look,
after its stat and before it opens and parses; "after-read" rewrites at the
after-read fingerprint check. Removing the after-read check makes
after-read fail. No other test changed.

New tests:
- TestUsageAppendReadHashesNoPreReadScan: 20,000-row settled log
  (2,760,615 bytes), 5 x Append + Summarize(Today), counts the bytes
  logRecordHash re-reads. origin/main: 7,728,984 bytes per cycle (2.80x the
  log), FAIL; this commit: 4,968,615 (1.80x), PASS. Wall time per cycle on
  an M-series Mac, 3 runs each: 3.43-3.52 ms before, 2.28-2.52 ms after
  (~32% less). The remaining two passes are the prefix check and the
  after-read check, which the same-tick guarantee needs.
- TestUsagePartialLineThenCompletion (settled and unsettled): another
  writer's half line, a read, then the line completed; the fingerprint
  covers the parsed bytes only and the completed record is counted. On
  origin/main it fails on the fingerprint check (it covered the half line).

Break checks: no after-read check -> RewriteBeforeFingerprint/after-read
fails; no prefix verification on a continuation -> SameSizeAndMtimeRewrite
append=true, SameTickRewrite append=true and LogGrowingRewriteThenAppend
fail; lines not fed into the digest -> SameTickRewrite fails.

Run: go test -tags nogui ./internal/usage/... ./internal/gateway/... (ok,
temp HOME, pinned caches, removed after); go test -race ./internal/usage/...
(ok); -race -count=10 on the rewrite/append tests (ok); gofmt -l (empty);
go vet, GOOS=linux and GOOS=windows go vet -tags nogui ./internal/usage/
(ok). Not tried on Windows or Linux hardware; the change is
platform-independent hashing over the same read.
2026-10-09 06:54:28 +08:00

272 lines
7.5 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"
"crypto/sha256"
"encoding"
"encoding/json"
"fmt"
"hash"
"io"
"maps"
"os"
"slices"
"sync"
"time"
)
const (
logBlockRows = 512
logStampSettle = time.Second
)
// 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
settled bool
path string
info os.FileInfo
off int64
hash string // SHA-256 of the log's first off bytes, the ones parsed
digest []byte // the hash's running state, which the next parse continues
version uint64
blocks []*rowChunk
first time.Time
keyProviders map[string]bool
}
var logIndex struct {
sync.Mutex
snapshot *logSnapshot
version uint64
}
var logIndexNow = time.Now
var logRecordHash = recordHash
// 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()
start := logIndexNow()
info, err := statLogFile(path)
settled := settledLogStamp(info, start)
old := logIndex.snapshot
unchanged := old != nil && old.path == path && (err != nil && old.info == nil || err == nil && sameLogInfo(old.info, info))
if unchanged && info != nil {
if logChangeStamp(info) == "" || !old.settled {
unchanged = old.hash != "" && logRecordHash(path, old.off) == old.hash
if unchanged && settled {
promoted := *old
promoted.info, promoted.settled = info, true
old = &promoted
logIndex.snapshot = old
}
} else {
unchanged = old.settled && settled
}
}
if unchanged && (!old.uncached || metadataOnly) {
return old
}
if !unchanged {
logIndex.version++
}
next := &logSnapshot{path: path, info: info, settled: settled, 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 := old != nil && old.settled && 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 != "" && logRecordHash(path, old.off) == old.hash)
// The parse is the fingerprint: it hashes the lines it reads, continuing
// the old snapshot's hash when it continues its blocks.
sum := sha256.New()
if continued && !old.uncached && resumeLogHash(sum, old.digest) {
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))
sum.Write(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())
}
next.hash, next.digest = fmt.Sprintf("%x", sum.Sum(nil)), marshalLogHash(sum)
// A rewrite during the read must not give old blocks the new content's hash.
if (!trusted || !settled) && next.hash != logRecordHash(path, next.off) {
next.hash, next.digest = "", nil
next.settled = false
}
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 resumeLogHash(h hash.Hash, state []byte) bool {
u, ok := h.(encoding.BinaryUnmarshaler)
if !ok || state == nil || u.UnmarshalBinary(state) != nil {
h.Reset()
return false
}
return true
}
func marshalLogHash(h hash.Hash) []byte {
m, ok := h.(encoding.BinaryMarshaler)
if !ok {
return nil
}
state, err := m.MarshalBinary()
if err != nil {
return nil
}
return state
}
func oldInfo(s *logSnapshot) os.FileInfo {
if s == nil {
return nil
}
return s.info
}
// A read in the change stamp's clock tick cannot prove that a later equal
// stamp still names the same bytes. Decide before reading, not at reuse time.
func settledLogStamp(info os.FileInfo, start time.Time) bool {
c := logChangeTime(info)
return !c.IsZero() && c.Before(start.Add(-logStampSettle))
}
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
}