Artifact 5123b323028a3c24c13f280abd2df906223ce5c17b04d614521d3b7e736594c0:
- File
internal/timing/timing.go
— part of check-in
[17454ef71f]
at
2026-08-31 15:37:28
on branch trunk
— the footer names store wait the timing context missed
A call on context.Background() carries no timer, so a page blocking six seconds on a warm pass reported 'ops 11.09s, zit 32.9ms' — and the ops panel's claim that ops minus zit is index work was false while it did. Overlap catches what attribution misses: subprocess busy time inside the render window, unioned, reported only when it exceeds what was already attributed. (user: quarry, size: 8522) [annotate] [blame] [check-ins using]
// Package timing accumulates where a request's time went, so the page // can say so. Fossil publishes a single number ("generated in about // 0.003s"); one number tells you a page is slow but not which layer to // look at, and quarry's layers have wildly different costs — an index // read is microseconds, a zit subprocess is milliseconds to seconds. // // The accumulator rides the request context. That is what makes it // correct under concurrency: package-level counters would blend every // in-flight request together, which is exactly the load this is meant to // measure. package timing import ( "context" "fmt" "sort" "strings" "sync" "time" ) // Category names a layer. Kept as a small closed set rather than free // text: the footer is a fixed-width place and an unbounded set of labels // would make it unreadable. type Category string const ( Zit Category = "zit" // subprocess calls to the store SQL Category = "sql" // index and OLTP queries // Ops is time inside op handlers, which CONTAINS zit and sql: the // registry is the seam every page's data passes through, and it is // one place rather than the hundreds a per-query wrapper would // touch. Reported as a container, never added to its contents. Ops Category = "ops" Tmpl Category = "tmpl" // template execution Chat Category = "chat" // event-plane round trips HTTP Category = "http" // outbound calls (notify, dagnats) Other Category = "other" ) type entry struct { // spans are the intervals the category was BUSY, not a running // sum. quarry runs subprocesses concurrently on purpose — a diff // is two zit calls at once — and summing them printed more time // than the whole request took: "rendered in 410ms (zit 706.8ms×2)" // on tcl, and "79.94s (zit 159.05s×2)" on a cold one. A reader // cannot act on a number larger than the total; it reads as a bug // in the page rather than as two calls overlapping. spans []span n int } // span is one observation's wall-clock interval. type span struct{ start, end time.Time } // busy is the length of the UNION of the spans: the wall time during // which at least one call in this category was running. Two concurrent // 350ms calls are 350ms of waiting, not 700ms. func busy(spans []span) time.Duration { if len(spans) == 0 { return 0 } sorted := make([]span, len(spans)) copy(sorted, spans) sort.Slice(sorted, func(i, j int) bool { return sorted[i].start.Before(sorted[j].start) }) var total time.Duration cur := sorted[0] for _, s := range sorted[1:] { if s.start.After(cur.end) { // a gap: bank the run and start over total += cur.end.Sub(cur.start) cur = s continue } if s.end.After(cur.end) { cur.end = s.end } } return total + cur.end.Sub(cur.start) } // Timing accumulates per-category durations and call counts. type Timing struct { mu sync.Mutex by map[Category]*entry } type ctxKey struct{} // With returns a context carrying a fresh accumulator, and the // accumulator. A request that never calls this still works: Record on a // context without one is a no-op, so instrumentation never depends on // the caller having opted in. func With(ctx context.Context) (context.Context, *Timing) { t := &Timing{by: map[Category]*entry{}} return context.WithValue(ctx, ctxKey{}, t), t } // From returns the context's accumulator, or nil. func From(ctx context.Context) *Timing { t, _ := ctx.Value(ctxKey{}).(*Timing) return t } // Record adds one observation, ending now. Safe on a context with no // accumulator and on a nil Timing, so call sites never need a guard. // // It takes the START rather than a duration: a duration cannot say // whether two calls overlapped, and overlap is the whole question on a // page that runs subprocesses concurrently. func Record(ctx context.Context, c Category, start time.Time) { From(ctx).Add(c, start, time.Now()) } // Track times fn and records it. The common shape at a call site. func Track[T any](ctx context.Context, c Category, fn func() (T, error)) (T, error) { start := time.Now() v, err := fn() Record(ctx, c, start) return v, err } func (t *Timing) Add(c Category, start, end time.Time) { if t == nil { return } t.mu.Lock() defer t.mu.Unlock() e := t.by[c] if e == nil { e = &entry{} t.by[c] = e } e.spans = append(e.spans, span{start, end}) e.n++ } // Breakdown renders the categories that actually did work, busiest // first, as "zit 147ms×1, sql 3ms×4". The number is WALL time the // category was busy, so "×2" beside 400ms means two calls overlapping // inside 400ms of waiting, not 800ms of work. Categories with no calls are // omitted rather than printed as zero: a footer listing every layer the // server has would be noise on the pages that touch one of them. // // It deliberately does NOT include a total. The total is wall time, // measured by the caller across the whole request; summing categories // would under-report everything the layers do not cover and invite the // reader to treat the difference as unaccounted rather than as // uninstrumented. func (t *Timing) Breakdown() string { if t == nil { return "" } t.mu.Lock() defer t.mu.Unlock() type row struct { c Category busy time.Duration n int } rows := make([]row, 0, len(t.by)) for c, e := range t.by { if e.n > 0 { rows = append(rows, row{c, busy(e.spans), e.n}) } } sort.Slice(rows, func(i, j int) bool { return rows[i].busy > rows[j].busy }) var b strings.Builder for i, r := range rows { if i > 0 { b.WriteString(", ") } fmt.Fprintf(&b, "%s %s", r.c, Format(r.busy)) if r.n > 1 { fmt.Fprintf(&b, "×%d", r.n) } } return b.String() } // Category is the wall time one category was busy, for a caller that // wants the numbers rather than the rendered string. func (t *Timing) Category(c Category) time.Duration { if t == nil { return 0 } t.mu.Lock() defer t.mu.Unlock() e := t.by[c] if e == nil { return 0 } return busy(e.spans) } // Format words a duration for the footer at roughly three significant // figures, so the number stays informative across the five orders of // magnitude quarry spans — an index read is tens of microseconds and a // cold listing is seconds. // // It does NOT floor at a millisecond. "<1ms" was true and useless: most // pages are now sub-millisecond, so the floor collapsed the whole // interesting range into one label and made an improvement from 900µs to // 90µs invisible. Fossil's own footer has the mirror-image problem — // it prints seconds to three decimals, so everything faster than a // millisecond reads as "0.000s". func Format(d time.Duration) string { us := float64(d) / float64(time.Microsecond) switch { case d <= 0: return "0" case us < 10: // 4.2µs return fmt.Sprintf("%.1fµs", us) case us < 1000: // 421µs return fmt.Sprintf("%.0fµs", us) case d < 10*time.Millisecond: // 4.21ms return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond)) case d < time.Second: // 42.1ms return fmt.Sprintf("%.1fms", float64(d)/float64(time.Millisecond)) default: // 4.21s return fmt.Sprintf("%.2fs", d.Seconds()) } } // WithStoreBusy adds a store-wait note to a breakdown when the store was // busy longer than this render's own attributed subprocess time. // // The two differ when a call runs on a detached context: the timer // lives in the request's context, so a warm pass or a memo fill spawned // with context.Background() is invisible to it while the render still // waits. Reporting the overlap keeps the footer's other claim honest — // that what is left after zit is index and OLTP work. func WithStoreBusy(breakdown string, busy time.Duration) string { if busy <= 0 { return breakdown } // Only worth saying when it exceeds what was already attributed; // otherwise the existing zit figure already covers this wait. if attributed := zitFrom(breakdown); attributed >= busy { return breakdown } note := "store " + Format(busy) if breakdown == "" { return note } return breakdown + ", " + note } // zitFrom reads the zit total already present in a breakdown, so the // store note is added only when it says something new. func zitFrom(breakdown string) time.Duration { i := strings.Index(breakdown, "zit ") if i < 0 { return 0 } rest := breakdown[i+len("zit "):] for j := 0; j < len(rest); j++ { if rest[j] == ',' || rest[j] == '×' { rest = rest[:j] break } } d, err := time.ParseDuration(strings.TrimSpace(rest)) if err != nil { return 0 } return d }