Files
memby/server/internal/timing/timing.go
2026-08-19 18:08:00 +12:00

269 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package timing answers "where did the time go" for one request.
//
// "The home screen took 9.5 seconds" is not actionable; "Emby 7.8s over four calls,
// row assembly 1.4s, Postgres 18ms" is. The gateway's slow requests are almost never
// slow in the gateway — they are waiting on Emby, on an *arr, on Postgres, or on
// several of those in a row — and until the request line could separate those, every
// investigation started by guessing.
//
// A trace is carried in the request context by pointer, the requestIdentity
// arrangement, so a layer four calls down can record against a trace the middleware
// created without anything in between having to know it exists. Everything here is a
// no-op when no trace is installed: a scheduled task, a health probe and every test
// call the same helpers and pay an interface-nil check.
//
// It measures *stages*, not spans in a tree. Most of what matters here is concurrent —
// Home fans four Emby queries out at once — so a total of wall-clock spans would
// exceed the request's own duration and mean nothing. What each stage reports is the
// summed busy time and the number of calls, and the call count is the half that finds
// duplicate work: "emby=7.8s ×14" on a page that should make two lookups is the
// finding, whatever the seconds say.
package timing
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// Stage names. They are constants rather than free strings so a typo cannot quietly
// create a second column that never lines up with the first.
const (
StageEmby = "emby"
StageDB = "db"
StageRedis = "redis"
StageSonarr = "sonarr"
StageRadarr = "radarr"
StageTracearr = "tracearr"
StageMDBList = "mdblist"
StageBazarr = "bazarr"
StageOpenSubtitles = "opensubtitles"
StageIntegrations = "integrations"
// Gateway-side work, named for what a reader would call it rather than for the
// function that does it.
StageRows = "rows"
StageRank = "rank"
StageRatings = "ratings"
StageHero = "hero"
StageEncode = "encode"
StageDecode = "decode"
)
type stage struct {
calls int
total time.Duration
}
// Trace collects one request's stage totals. It is written from every goroutine a
// handler fans out to, so it is locked; the lock is held for a map write and nothing
// else, which is nothing beside the work being measured.
type Trace struct {
mu sync.Mutex
stages map[string]*stage
counts map[string]int
started time.Time
}
type traceKey struct{}
// New installs a trace on ctx and returns both. The caller keeps the pointer so it can
// read the breakdown after the handler has returned.
func New(ctx context.Context) (context.Context, *Trace) {
t := &Trace{
stages: make(map[string]*stage, 8),
counts: make(map[string]int, 4),
started: time.Now(),
}
return context.WithValue(ctx, traceKey{}, t), t
}
// From returns the trace on ctx, or nil outside a traced request.
func From(ctx context.Context) *Trace {
if ctx == nil {
return nil
}
t, _ := ctx.Value(traceKey{}).(*Trace)
return t
}
// Record adds one call's worth of time to a stage. Safe on a nil trace and on an
// untraced context, which is what lets call sites record unconditionally.
func Record(ctx context.Context, name string, d time.Duration) {
From(ctx).Record(name, d)
}
func (t *Trace) Record(name string, d time.Duration) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
s := t.stages[name]
if s == nil {
s = &stage{}
t.stages[name] = s
}
s.calls++
s.total += d
}
// Start begins a stage and returns the function that ends it. The idiom at the call
// site is `defer timing.Start(ctx, timing.StageRows)()`, which is why the returned
// func takes no argument: a stop that needed a value would be a stop somebody forgets
// to call on the error path.
func Start(ctx context.Context, name string) func() {
t := From(ctx)
if t == nil {
return func() {}
}
began := time.Now()
return func() { t.Record(name, time.Since(began)) }
}
// Count records a tally with no duration — a cache hit, a cache miss, a deduplicated
// upstream call. "Cache MISS" is the first thing anybody wants to know about a slow
// screen and it has no time of its own to report.
func Count(ctx context.Context, name string) {
t := From(ctx)
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.counts[name]++
}
// Empty reports whether anything was recorded. A request that touched nothing
// measurable should print no breakdown rather than an empty one, which reads as a
// breakdown that failed.
func (t *Trace) Empty() bool {
if t == nil {
return true
}
t.mu.Lock()
defer t.mu.Unlock()
return len(t.stages) == 0 && len(t.counts) == 0
}
// Breakdown renders the trace as one scannable field:
//
// emby=7.81s×4 rows=1.40s db=18ms×3 redis=2ms×2 encode=31ms miss=1
//
// Ordered by time spent, largest first, because the first term is the answer in almost
// every case. Counts follow the stages, since they qualify the timings rather than
// competing with them.
func (t *Trace) Breakdown() string {
if t == nil {
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
type entry struct {
name string
s stage
}
entries := make([]entry, 0, len(t.stages))
for name, s := range t.stages {
entries = append(entries, entry{name: name, s: *s})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].s.total != entries[j].s.total {
return entries[i].s.total > entries[j].s.total
}
return entries[i].name < entries[j].name
})
parts := make([]string, 0, len(entries)+len(t.counts))
for _, e := range entries {
part := e.name + "=" + formatDuration(e.s.total)
// One call is the ordinary case and saying so on every term would make the
// line harder to read, not easier. A repeat is the interesting number.
if e.s.calls > 1 {
part += fmt.Sprintf("×%d", e.s.calls)
}
parts = append(parts, part)
}
names := make([]string, 0, len(t.counts))
for name := range t.counts {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
parts = append(parts, fmt.Sprintf("%s=%d", name, t.counts[name]))
}
return strings.Join(parts, " ")
}
// Unattributed is the request's own time less its largest stage.
//
// It is deliberately not called "gateway processing", and it is deliberately not the
// duration less the *sum* of the stages. Stages overlap — Home fans four Emby queries
// out at once — so a sum would routinely exceed the request's own duration and report a
// negative remainder. What this is instead is a lower bound on time nothing accounted
// for: zero means the evidence explains the request, and a large figure means something
// on the path is not being measured.
func (t *Trace) Unattributed(total time.Duration) time.Duration {
if t == nil {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
var largest time.Duration
for _, s := range t.stages {
if s.total > largest {
largest = s.total
}
}
if remainder := total - largest; remainder > 0 {
return remainder
}
return 0
}
// formatDuration keeps the column narrow: milliseconds under a second, two decimals
// above it. time.Duration's own String prints "7.812345678s", which is nine digits of
// precision nobody reading a request line has any use for.
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Round(time.Millisecond)/time.Millisecond)
}
return fmt.Sprintf("%.2fs", d.Seconds())
}
type labelKey struct{}
// WithLabel names the stage that upstream calls made under ctx record against, in place
// of the client's own.
//
// The reason it exists is Home: five Emby queries run concurrently, and a breakdown
// reading `emby=7.81s×5` says the launcher waited on Emby without saying which of the
// five it waited on — which is the whole of the next question. Labelled, the same
// request reports `emby.favourites=6.90s emby.resume=310ms emby.nextup=290ms …` and the
// answer is the first term.
//
// It is a context value rather than an argument because the thing being labelled is
// several layers below the thing that knows the name: the row's fetch closure knows it
// is the favourites row, and the transport that measures it is inside an HTTP client two
// packages away. A label is only ever a *finer* name for a stage the client would have
// recorded anyway, so nothing is lost where it is absent.
func WithLabel(ctx context.Context, label string) context.Context {
if label == "" {
return ctx
}
return context.WithValue(ctx, labelKey{}, label)
}
// LabelFrom returns the stage name in force for ctx, falling back to the client's own.
func LabelFrom(ctx context.Context, fallback string) string {
if label, ok := ctx.Value(labelKey{}).(string); ok && label != "" {
return label
}
return fallback
}