Files
magpie/internal/sessions/call_cache.go
T
yetone f1ca6139b5 usage: a Codex call through the gateway that wrote to its upstream's cache is no longer listed a second time as a local session call (laowangba233 on #589: 用量中的统计是不是重复了 … 为啥会一个是提供商的一个是本地会话的) — the Usage page drops a Codex rollout's call when it matches a gateway record by session, end time within 2s and equal tokens. Codex reads input_tokens_details.cache_write_tokens from a Responses reply and keeps it as cache_write_input_tokens; the gateway's rUsage read only cached_tokens, so for a call whose upstream told a write the record had CacheWrite 0 and the rollout didn't, the tokens never matched, and the call stayed listed under Local session beside its provider's row. Calls without a write matched, so only some calls were counted twice (Maru Code 92 calls plus 22 "本地会话" in the report). Also, sessions' spent took only the cache read out of Codex's input_tokens, which holds the write too, so such a call's write was counted twice in its own input. Now: the gateway reads cache_write_tokens (input = input_tokens - cached - written) and tells it to Codex when it translates a reply; spent takes the write out of the input as well; and the matcher compares a call's input plus cache writes, its output and its cache reads, so a record and a rollout that split the write differently — an older Codex without the field, or records already in usage.jsonl from before this change — still match, and the duplicates already on a user's page go too. The session caches' versions go up (sessions.json 11, calls-v4) so kept parses are read again, and older calls-v* folders are removed. Under a provider filter the provider ranking still lists the other providers, as it is a picker; the duplicates were what made it read as double counting. TestCodexCacheWriteMatchesGatewayCall (a Codex rollout and gateway records for two calls, one with a 9000-token cache write; with the rollout naming the write and the record not, both naming it, and an older Codex without it: two rows, no Local session row, the provider ranking maru-code alone, unfiltered and filtered to the provider, by QueryPage and LedgerOf; fails without the change with a third row from the log), TestCodexCacheWriteNotInInput and TestResponsesUsageCacheWrite (parse and round trip through what Codex is told) are new and fail without the change; TestCallsCodex's call with a write now expects input 200 (2500 - 2000 read - 300 written). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./internal/... under a temp HOME pass (TestFXInState's known TempDir-cleanup flake aside, which passes alone).
2026-10-03 00:47:33 +08:00

329 lines
7.9 KiB
Go

package sessions
// Request metadata uses checksummed append-only frames. A growing session saves
// only new/changed calls and its parser continuation. Corrupt shards rebuild
// from source. Sessions summaries never load these files.
import (
"bytes"
"encoding/binary"
"encoding/gob"
"hash/crc32"
"hash/fnv"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// v3 persists Codex cumulative counters explicitly; v2 shards can contain
// inflated deltas after a reload, so rebuild their derived rows from source.
// v4: a Codex call's input no longer holds what it wrote to the cache (#589).
const callCacheVersion = "calls-v4"
const maxKeptCalls = 131072
const maxKeptFiles = 64
var callsMu sync.Mutex
var callIO [64]sync.Mutex
var callGeneration uint64
var callCache = map[string]*callFile{}
var callCounts = map[string]int{} // small allocation hints, never call records
var callOrder []string
var callRoot string
func callCacheDir() string { return filepath.Join(filepath.Dir(CachePath()), callCacheVersion) }
func callCachePath(path string) string { return filepath.Join(callCacheDir(), hashHead(path)+".gob") }
func resetCalls() {
callsMu.Lock()
defer callsMu.Unlock()
callCache, callOrder = map[string]*callFile{}, nil
callCounts = map[string]int{}
callRoot = ""
callGeneration++
}
func pruneCalls(files []file) {
root := callCacheDir()
callsMu.Lock()
moved := callRoot != root
if moved {
callGeneration++
callCache, callOrder, callRoot = map[string]*callFile{}, nil, root
callCounts = map[string]int{}
}
on := make(map[string]bool, len(files))
shards := make(map[string]bool, len(files))
for _, f := range files {
on[f.path] = true
shards[hashHead(f.path)+".gob"] = true
}
order := callOrder[:0]
for _, path := range callOrder {
if on[path] {
order = append(order, path)
} else {
delete(callCache, path)
}
}
callOrder = order
for path := range callCounts {
if !on[path] {
delete(callCounts, path)
}
}
callsMu.Unlock()
if moved {
// an older version's shards are never read again
olds, _ := filepath.Glob(filepath.Join(filepath.Dir(root), "calls-v*"))
for _, old := range olds {
if old != root {
_ = os.RemoveAll(old)
}
}
}
// IO never holds the metadata lock. A grace period protects live writers.
entries, _ := os.ReadDir(root)
for _, e := range entries {
if e.IsDir() {
continue
}
orphan := strings.HasSuffix(e.Name(), ".gob") && !shards[e.Name()]
if strings.HasPrefix(e.Name(), "calls-") && strings.HasSuffix(e.Name(), ".tmp") {
if st, err := e.Info(); err == nil && time.Since(st.ModTime()) > time.Hour {
orphan = true
}
}
if orphan {
_ = os.Remove(filepath.Join(root, e.Name()))
}
}
}
func keepCalls(path string, st *callFile) {
callCounts[path] = len(st.Calls)
delete(callCache, path)
order := callOrder[:0]
for _, p := range callOrder {
if p != path {
order = append(order, p)
}
}
callOrder = order
if len(st.Calls) > maxKeptCalls {
return
}
n := len(st.Calls)
for _, s := range callCache {
n += len(s.Calls)
}
for len(callOrder) > 0 && (n > maxKeptCalls || len(callOrder) >= maxKeptFiles) {
p := callOrder[0]
n -= len(callCache[p].Calls)
delete(callCache, p)
callOrder = callOrder[1:]
}
callCache[path] = st
callOrder = append(callOrder, path)
}
func readCalls(f file) *callFile {
h := fnv.New32a()
_, _ = h.Write([]byte(f.path))
lock := &callIO[h.Sum32()%uint32(len(callIO))]
lock.Lock()
defer lock.Unlock()
callsMu.Lock()
old, generation := callCache[f.path], callGeneration
callsMu.Unlock()
if old == nil {
old = loadCalls(f)
}
if old != nil && old.Size == f.size && old.Mod == f.mod.UnixNano() {
callsMu.Lock()
if generation == callGeneration {
keepCalls(f.path, old)
}
callsMu.Unlock()
return old
}
st := prepareCalls(f, old)
continued := old != nil && st.Off == old.Off && st.Off > 0
start := 0
if continued {
start = len(old.Calls)
}
off, err := scanAt(f.path, st.Off, func(b []byte) bool { return callHead(st, b) }, func(b []byte, start, end int64) bool {
st.At, st.End = start, end
if f.agent == "codex" {
codexCallLine(st, b)
} else {
claudeCallLine(st, b)
}
return true
})
if err == nil {
st.Off, st.Size, st.Mod = off, f.size, f.mod.UnixNano()
st.ContentHash = prefixHash(f.path, f.size)
writeCalls(st, start, continued)
}
st.dirty = nil
callsMu.Lock()
if generation == callGeneration {
keepCalls(f.path, st)
}
callsMu.Unlock()
return st
}
// Common strings are stored once per append frame; File is implied by State.
type callPatch struct {
Index int
Call Call
Text [12]uint32
Began time.Time
}
type callFrame struct {
State callFile
Strings []string
Patches []callPatch
}
func callText(c *Call) [12]*string {
return [12]*string{&c.Agent, &c.Session, &c.Model, &c.Requested, &c.Effort, &c.Cwd, &c.Upstream, &c.AccountID, &c.UserID, &c.Error, &c.ErrorText, &c.File}
}
func loadCalls(f file) *callFile {
in, err := os.Open(callCachePath(f.path))
if err != nil {
return nil
}
defer in.Close()
var st *callFile
var calls []Call
msgs := map[string]int{}
began := map[string]time.Time{}
for {
var header [8]byte
_, err = io.ReadFull(in, header[:])
if err == io.EOF {
break
}
if err != nil {
return nil
}
size := binary.LittleEndian.Uint32(header[:4])
if size == 0 || size > 256<<20 {
return nil
}
data := make([]byte, int(size))
if _, err = io.ReadFull(in, data); err != nil || crc32.ChecksumIEEE(data) != binary.LittleEndian.Uint32(header[4:]) {
return nil
}
var frame callFrame
if gob.NewDecoder(bytes.NewReader(data)).Decode(&frame) != nil || frame.State.Path != f.path || frame.State.Agent != f.agent {
return nil
}
if st != nil && frame.State.Off < st.Off {
return nil
}
for _, p := range frame.Patches {
if p.Index < 0 || p.Index > len(calls) {
return nil
}
for i, field := range callText(&p.Call) {
if int(p.Text[i]) >= len(frame.Strings) {
return nil
}
*field = frame.Strings[p.Text[i]]
}
p.Call.File = f.path
if p.Index == len(calls) {
calls = append(calls, p.Call)
} else {
calls[p.Index] = p.Call
}
if p.Call.Msg != "" {
msgs[p.Call.Msg] = p.Index
if !p.Began.IsZero() {
began[p.Call.Msg] = p.Began
}
}
}
st = &frame.State
}
if st == nil {
return nil
}
st.Calls, st.Msgs, st.Began = calls, msgs, began
return st
}
func writeCalls(st *callFile, start int, appendOnly bool) {
frame := callFrame{State: *st, Strings: []string{""}}
frame.State.Calls = nil
frame.State.Msgs = nil
frame.State.Began = nil
frame.State.Strs = nil
dict := map[string]uint32{"": 0}
add := func(i int) {
p := callPatch{Index: i, Call: st.Calls[i], Began: st.Began[st.Calls[i].Msg]}
p.Call.File = ""
for j, field := range callText(&p.Call) {
id, ok := dict[*field]
if !ok {
id = uint32(len(frame.Strings))
dict[*field] = id
frame.Strings = append(frame.Strings, *field)
}
p.Text[j] = id
*field = ""
}
frame.Patches = append(frame.Patches, p)
}
for i := 0; i < start; i++ {
if st.dirty[i] {
add(i)
}
}
for i := start; i < len(st.Calls); i++ {
add(i)
}
var data bytes.Buffer
if gob.NewEncoder(&data).Encode(frame) != nil {
return
}
var header [8]byte
binary.LittleEndian.PutUint32(header[:4], uint32(data.Len()))
binary.LittleEndian.PutUint32(header[4:], crc32.ChecksumIEEE(data.Bytes()))
dir := callCacheDir()
if os.MkdirAll(dir, 0700) != nil {
return
}
var out *os.File
var err error
if appendOnly {
out, err = os.OpenFile(callCachePath(st.Path), os.O_WRONLY|os.O_APPEND, 0600)
} else {
out, err = os.CreateTemp(dir, "calls-*.tmp")
}
if err != nil {
return
}
if !appendOnly {
defer os.Remove(out.Name())
}
_, err = out.Write(header[:])
if err == nil {
_, err = out.Write(data.Bytes())
}
if closeErr := out.Close(); err == nil {
err = closeErr
}
if err == nil && !appendOnly {
_ = os.Rename(out.Name(), callCachePath(st.Path))
}
}