0.2.78
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* The expensive half, and the reason it is a separate route.
|
||||
|
||||
Reading every goroutine's stack means stopping the world for as long as it takes to walk
|
||||
them all. That is measured in milliseconds rather than seconds, but it is not something
|
||||
to do on a poll every open console tab makes every thirty seconds — so this is collected
|
||||
only when an operator asks, and the answer says when it was collected and what it cost.
|
||||
|
||||
What comes back is a text dump, and parsing text a runtime produced is exactly the sort
|
||||
of thing that quietly stops working. Two rules keep that honest: parseGoroutines is pure
|
||||
and pinned by tests carrying real dump text, and every unrecognised state or frame falls
|
||||
into a named "other" bucket rather than being dropped — a breakdown whose parts do not
|
||||
add up to the total is worse than no breakdown, so the total is always the count of
|
||||
goroutines parsed and the categories always partition it. */
|
||||
|
||||
// Category is the readable grouping a goroutine state falls into. These are the words an
|
||||
// operator is being asked to reason with, so there are six of them and none is Go jargon.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryRunning Category = "running"
|
||||
CategoryIO Category = "io"
|
||||
CategoryWaiting Category = "waiting"
|
||||
CategoryTimers Category = "timers"
|
||||
CategoryRuntime Category = "runtime"
|
||||
CategoryOther Category = "other"
|
||||
)
|
||||
|
||||
// categoryLabels is the console's wording. Sent from here, the stance every label the
|
||||
// gateway prints takes: a category added later reads correctly on a console that predates
|
||||
// it, because the console prints what it was handed.
|
||||
var categoryLabels = map[Category]string{
|
||||
CategoryRunning: "Running",
|
||||
CategoryIO: "Network / I/O",
|
||||
CategoryWaiting: "Waiting / idle",
|
||||
CategoryTimers: "Timers / scheduled work",
|
||||
CategoryRuntime: "Go runtime / collection",
|
||||
CategoryOther: "Other",
|
||||
}
|
||||
|
||||
// categoryOrder is the order they are presented in: busiest kind of work first, then the
|
||||
// two that mean the goroutine is doing nothing, then the runtime's own housekeeping. It is
|
||||
// fixed rather than sorted by count so the table does not reorder between two collections.
|
||||
var categoryOrder = []Category{
|
||||
CategoryRunning, CategoryIO, CategoryWaiting, CategoryTimers, CategoryRuntime, CategoryOther,
|
||||
}
|
||||
|
||||
// categoryDescriptions say what the operator is looking at, because "semacquire" means
|
||||
// nothing to somebody who does not write Go and the whole point of this page is that they
|
||||
// should not have to.
|
||||
var categoryDescriptions = map[Category]string{
|
||||
CategoryRunning: "On a processor now, or queued for one.",
|
||||
CategoryIO: "Waiting on a network read or write — Emby, Postgres, Redis, or a television.",
|
||||
CategoryWaiting: "Parked waiting for work or for a lock. Idle, and costing almost nothing.",
|
||||
CategoryTimers: "Asleep until a scheduled time.",
|
||||
CategoryRuntime: "The Go runtime's own housekeeping. This handful is always present.",
|
||||
CategoryOther: "A state this build does not have a category for.",
|
||||
}
|
||||
|
||||
// categoryFor maps a runtime state string onto one of the six. The GC states are tested
|
||||
// first because several of them contain words the later rules would otherwise claim —
|
||||
// "wait for GC cycle" is the runtime's own, not a component waiting on a channel.
|
||||
func categoryFor(state string) Category {
|
||||
state = strings.TrimSpace(state)
|
||||
lower := strings.ToLower(state)
|
||||
switch {
|
||||
case strings.Contains(lower, "gc "), strings.HasPrefix(lower, "gc"),
|
||||
lower == "finalizer wait", strings.HasPrefix(lower, "trace reader"),
|
||||
strings.HasPrefix(lower, "dumping heap"), strings.HasPrefix(lower, "stopping the world"),
|
||||
strings.HasPrefix(lower, "idle"):
|
||||
return CategoryRuntime
|
||||
case lower == "running", lower == "runnable", strings.HasPrefix(lower, "syscall"):
|
||||
return CategoryRunning
|
||||
case strings.HasPrefix(lower, "io wait"), strings.HasPrefix(lower, "netpoll"):
|
||||
return CategoryIO
|
||||
case strings.HasPrefix(lower, "sleep"), strings.HasPrefix(lower, "timer"):
|
||||
return CategoryTimers
|
||||
case strings.HasPrefix(lower, "select"), strings.HasPrefix(lower, "chan "),
|
||||
strings.HasPrefix(lower, "semacquire"), strings.HasPrefix(lower, "sync."),
|
||||
strings.HasPrefix(lower, "wait"), strings.HasPrefix(lower, "preempted"):
|
||||
return CategoryWaiting
|
||||
default:
|
||||
return CategoryOther
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- component attribution ---------- */
|
||||
|
||||
type componentRule struct {
|
||||
component string
|
||||
// patterns are matched against every frame of the stack — the function names and the
|
||||
// file paths alike — plus the "created by" line, which is often the only frame that
|
||||
// still names the component a long-parked goroutine belongs to.
|
||||
patterns []string
|
||||
}
|
||||
|
||||
// componentRules is ordered, and the order is the whole of it: Memby's own packages are
|
||||
// tested before the libraries they call, or every database query in the gateway would be
|
||||
// filed under "Database pool" and nothing would be attributable to the feature that made
|
||||
// it. First match wins.
|
||||
var componentRules = []componentRule{
|
||||
{"Library sync", []string{"/internal/library"}},
|
||||
{"Scheduled jobs", []string{"/internal/scheduler"}},
|
||||
{"Credits detection", []string{"/internal/credits"}},
|
||||
{"Recommendations", []string{"/internal/recommend", "/internal/foryou", "/internal/tracearr"}},
|
||||
{"Notifications", []string{"/internal/notify"}},
|
||||
{"Integrations", []string{"/internal/integrations", "/internal/adminevents"}},
|
||||
{"Subtitles", []string{"/internal/bazarr", "/internal/opensubtitles", "/internal/subsync"}},
|
||||
{"Sonarr and Radarr", []string{"/internal/sonarr", "/internal/radarr"}},
|
||||
{"Ratings", []string{"/internal/mdblist"}},
|
||||
{"Emby", []string{"/internal/emby", "/internal/trickplay"}},
|
||||
{"Gateway API", []string{"/internal/api", "/internal/store", "/internal/cache", "/internal/logging"}},
|
||||
{"Database pool", []string{"jackc/pgx", "jackc/puddle", "database/sql"}},
|
||||
{"Redis", []string{"redis/go-redis"}},
|
||||
{"HTTP server", []string{"net/http.(*conn).serve", "net/http.(*Server)", "net/http.(*connReader)"}},
|
||||
{"HTTP client", []string{"net/http.(*persistConn)", "net/http.(*Transport)"}},
|
||||
{"Go runtime", []string{"runtime.", "runtime/pprof"}},
|
||||
}
|
||||
|
||||
const componentUnattributed = "Unattributed"
|
||||
|
||||
func componentFor(info goroutineInfo) string {
|
||||
haystack := info.searchText()
|
||||
for _, rule := range componentRules {
|
||||
for _, pattern := range rule.patterns {
|
||||
if strings.Contains(haystack, pattern) {
|
||||
return rule.component
|
||||
}
|
||||
}
|
||||
}
|
||||
return componentUnattributed
|
||||
}
|
||||
|
||||
/* ---------- parsing ---------- */
|
||||
|
||||
type frame struct {
|
||||
Function string
|
||||
File string
|
||||
}
|
||||
|
||||
type goroutineInfo struct {
|
||||
ID int
|
||||
State string
|
||||
WaitMinutes int
|
||||
Frames []frame
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func (info goroutineInfo) searchText() string {
|
||||
var builder strings.Builder
|
||||
for _, one := range info.Frames {
|
||||
builder.WriteString(one.Function)
|
||||
builder.WriteByte('\n')
|
||||
builder.WriteString(one.File)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString(info.CreatedBy)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// topFrame is the function the goroutine is actually sitting in, skipping the runtime
|
||||
// plumbing that every parked goroutine shares. Without the skip, half the table reads
|
||||
// "runtime.gopark", which is true of every waiting goroutine in the process and therefore
|
||||
// tells nobody anything.
|
||||
func (info goroutineInfo) topFrame() frame {
|
||||
for _, one := range info.Frames {
|
||||
function := one.Function
|
||||
if strings.HasPrefix(function, "runtime.") || strings.HasPrefix(function, "internal/poll.runtime_") ||
|
||||
strings.HasPrefix(function, "sync.runtime_") || strings.HasPrefix(function, "sync.(*") ||
|
||||
strings.HasPrefix(function, "time.Sleep") {
|
||||
continue
|
||||
}
|
||||
return one
|
||||
}
|
||||
if len(info.Frames) > 0 {
|
||||
return info.Frames[0]
|
||||
}
|
||||
return frame{}
|
||||
}
|
||||
|
||||
// parseGoroutines reads a runtime.Stack dump. It is pure so the shape of the dump can be
|
||||
// pinned by a test rather than discovered when a Go release changes it.
|
||||
func parseGoroutines(dump string) []goroutineInfo {
|
||||
var out []goroutineInfo
|
||||
var current *goroutineInfo
|
||||
flush := func() {
|
||||
if current != nil {
|
||||
out = append(out, *current)
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(dump, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "goroutine "):
|
||||
flush()
|
||||
info, ok := parseHeader(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
current = &info
|
||||
case current == nil:
|
||||
continue
|
||||
case strings.HasPrefix(line, "created by "):
|
||||
current.CreatedBy = strings.TrimPrefix(line, "created by ")
|
||||
case strings.HasPrefix(line, "\t"):
|
||||
// A file line belongs to the function line above it.
|
||||
if len(current.Frames) > 0 {
|
||||
path := strings.TrimSpace(line)
|
||||
if cut := strings.LastIndex(path, " +0x"); cut > 0 {
|
||||
path = path[:cut]
|
||||
}
|
||||
current.Frames[len(current.Frames)-1].File = path
|
||||
}
|
||||
case strings.TrimSpace(line) == "":
|
||||
flush()
|
||||
default:
|
||||
current.Frames = append(current.Frames, frame{Function: functionName(line)})
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHeader reads `goroutine 42 [select, 5 minutes]:`.
|
||||
func parseHeader(line string) (goroutineInfo, bool) {
|
||||
open := strings.Index(line, "[")
|
||||
shut := strings.LastIndex(line, "]")
|
||||
if open < 0 || shut < open {
|
||||
return goroutineInfo{}, false
|
||||
}
|
||||
id, _ := strconv.Atoi(strings.TrimSpace(line[len("goroutine "):open]))
|
||||
inside := line[open+1 : shut]
|
||||
state := inside
|
||||
minutes := 0
|
||||
// The state itself can contain a comma — "chan receive, 5 minutes" and "GC worker
|
||||
// (idle)" both do not, but "semacquire, 3 minutes, locked to thread" does, so only a
|
||||
// part that parses as a duration is taken as one.
|
||||
parts := strings.Split(inside, ", ")
|
||||
state = parts[0]
|
||||
for _, part := range parts[1:] {
|
||||
if value, ok := parseMinutes(part); ok {
|
||||
minutes = value
|
||||
}
|
||||
}
|
||||
return goroutineInfo{ID: id, State: state, WaitMinutes: minutes}, true
|
||||
}
|
||||
|
||||
func parseMinutes(part string) (int, bool) {
|
||||
part = strings.TrimSpace(part)
|
||||
if !strings.HasSuffix(part, " minutes") && !strings.HasSuffix(part, " minute") {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.Atoi(strings.Fields(part)[0])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// functionName strips the argument list a stack dump prints after the function, since the
|
||||
// arguments are addresses and would make every otherwise-identical goroutine its own group.
|
||||
func functionName(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if open := strings.Index(line, "("); open > 0 {
|
||||
// Method receivers are themselves parenthesised — `pkg.(*Type).Method(0x1)` — so
|
||||
// the argument list is the last balanced group rather than the first bracket.
|
||||
if last := strings.LastIndex(line, "("); last > 0 && !strings.HasSuffix(line[:last], ".") {
|
||||
return line[:last]
|
||||
}
|
||||
return line[:open]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/* ---------- the report ---------- */
|
||||
|
||||
type CategoryCount struct {
|
||||
Category Category `json:"category"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Count int `json:"count"`
|
||||
States []StateCount `json:"states"`
|
||||
}
|
||||
|
||||
type StateCount struct {
|
||||
State string `json:"state"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type ComponentCount struct {
|
||||
Component string `json:"component"`
|
||||
Count int `json:"count"`
|
||||
// LongestWaitMinutes is the age of the oldest goroutine attributed here. A component
|
||||
// whose count is climbing and whose oldest is hours old is the shape of a leak; one
|
||||
// that is busy and young is the shape of a busy evening.
|
||||
LongestWaitMinutes int `json:"longestWaitMinutes"`
|
||||
}
|
||||
|
||||
type StackGroup struct {
|
||||
Count int `json:"count"`
|
||||
Component string `json:"component"`
|
||||
Category Category `json:"category"`
|
||||
State string `json:"state"`
|
||||
Function string `json:"function"`
|
||||
File string `json:"file"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
LongestWaitMinutes int `json:"longestWaitMinutes"`
|
||||
}
|
||||
|
||||
type GoroutineReport struct {
|
||||
At time.Time `json:"at"`
|
||||
Total int `json:"total"`
|
||||
CollectedInMs float64 `json:"collectedInMs"`
|
||||
DumpBytes int `json:"dumpBytes"`
|
||||
Categories []CategoryCount `json:"categories"`
|
||||
Components []ComponentCount `json:"components"`
|
||||
Groups []StackGroup `json:"groups"`
|
||||
// GroupsTotal is how many distinct groups there were before the cap below; a gateway
|
||||
// with thousands of goroutines has a long tail that would be a page nobody reads.
|
||||
GroupsTotal int `json:"groupsTotal"`
|
||||
Workers []Worker `json:"workers"`
|
||||
}
|
||||
|
||||
// maxGroups caps the table. The groups are sorted by count, so what is dropped is the tail
|
||||
// of one-off goroutines, which is exactly the part that says nothing.
|
||||
const maxGroups = 40
|
||||
|
||||
// CollectGoroutines takes the dump and builds the report. This is the expensive call.
|
||||
func CollectGoroutines() GoroutineReport {
|
||||
begin := time.Now()
|
||||
dump := stackDump()
|
||||
report := buildReport(dump, time.Now())
|
||||
report.CollectedInMs = float64(time.Since(begin).Microseconds()) / 1000
|
||||
report.Workers = Workers()
|
||||
return report
|
||||
}
|
||||
|
||||
// StackDump is the raw text, for an operator who wants the whole thing rather than the
|
||||
// summary — the on-demand snapshot that replaces any temptation to leave a profiler
|
||||
// endpoint permanently open.
|
||||
func StackDump() string { return stackDump() }
|
||||
|
||||
// stackDump grows its buffer until the whole dump fits. runtime.Stack truncates silently
|
||||
// at the length of the buffer it is given, and a truncated dump would produce a breakdown
|
||||
// that is quietly missing whichever goroutines came last — which on a leak is the ones
|
||||
// worth seeing.
|
||||
func stackDump() string {
|
||||
size := 1 << 20
|
||||
for {
|
||||
buffer := make([]byte, size)
|
||||
written := runtime.Stack(buffer, true)
|
||||
if written < len(buffer) {
|
||||
return string(buffer[:written])
|
||||
}
|
||||
if size >= 1<<26 {
|
||||
return string(buffer[:written])
|
||||
}
|
||||
size *= 2
|
||||
}
|
||||
}
|
||||
|
||||
func buildReport(dump string, at time.Time) GoroutineReport {
|
||||
list := parseGoroutines(dump)
|
||||
report := GoroutineReport{At: at, Total: len(list), DumpBytes: len(dump)}
|
||||
|
||||
states := map[Category]map[string]int{}
|
||||
counts := map[Category]int{}
|
||||
components := map[string]*ComponentCount{}
|
||||
groups := map[string]*StackGroup{}
|
||||
|
||||
for _, info := range list {
|
||||
category := categoryFor(info.State)
|
||||
counts[category]++
|
||||
if states[category] == nil {
|
||||
states[category] = map[string]int{}
|
||||
}
|
||||
states[category][info.State]++
|
||||
|
||||
component := componentFor(info)
|
||||
entry := components[component]
|
||||
if entry == nil {
|
||||
entry = &ComponentCount{Component: component}
|
||||
components[component] = entry
|
||||
}
|
||||
entry.Count++
|
||||
if info.WaitMinutes > entry.LongestWaitMinutes {
|
||||
entry.LongestWaitMinutes = info.WaitMinutes
|
||||
}
|
||||
|
||||
top := info.topFrame()
|
||||
key := component + "\x00" + info.State + "\x00" + top.Function
|
||||
group := groups[key]
|
||||
if group == nil {
|
||||
group = &StackGroup{
|
||||
Component: component, Category: category, State: info.State,
|
||||
Function: top.Function, File: top.File, CreatedBy: info.CreatedBy,
|
||||
}
|
||||
groups[key] = group
|
||||
}
|
||||
group.Count++
|
||||
if info.WaitMinutes > group.LongestWaitMinutes {
|
||||
group.LongestWaitMinutes = info.WaitMinutes
|
||||
}
|
||||
}
|
||||
|
||||
for _, category := range categoryOrder {
|
||||
count := counts[category]
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
report.Categories = append(report.Categories, CategoryCount{
|
||||
Category: category,
|
||||
Label: categoryLabels[category],
|
||||
Description: categoryDescriptions[category],
|
||||
Count: count,
|
||||
States: sortedStates(states[category]),
|
||||
})
|
||||
}
|
||||
|
||||
for _, entry := range components {
|
||||
report.Components = append(report.Components, *entry)
|
||||
}
|
||||
sort.Slice(report.Components, func(a, b int) bool {
|
||||
if report.Components[a].Count != report.Components[b].Count {
|
||||
return report.Components[a].Count > report.Components[b].Count
|
||||
}
|
||||
return report.Components[a].Component < report.Components[b].Component
|
||||
})
|
||||
|
||||
for _, group := range groups {
|
||||
report.Groups = append(report.Groups, *group)
|
||||
}
|
||||
sort.Slice(report.Groups, func(a, b int) bool {
|
||||
if report.Groups[a].Count != report.Groups[b].Count {
|
||||
return report.Groups[a].Count > report.Groups[b].Count
|
||||
}
|
||||
return report.Groups[a].Function < report.Groups[b].Function
|
||||
})
|
||||
report.GroupsTotal = len(report.Groups)
|
||||
if len(report.Groups) > maxGroups {
|
||||
report.Groups = report.Groups[:maxGroups]
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func sortedStates(counts map[string]int) []StateCount {
|
||||
out := make([]StateCount, 0, len(counts))
|
||||
for state, count := range counts {
|
||||
out = append(out, StateCount{State: state, Count: count})
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if out[a].Count != out[b].Count {
|
||||
return out[a].Count > out[b].Count
|
||||
}
|
||||
return out[a].State < out[b].State
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Health is the answer the Process card exists to give: is the gateway healthy, and if
|
||||
// something is abnormal, which area should be looked at?
|
||||
//
|
||||
// It is stated on the server rather than derived in the console for the same reason every
|
||||
// schedule label is the gateway's wording — the thresholds and the sentence explaining
|
||||
// them belong together, and an older console must not be the thing deciding what "watch"
|
||||
// means. It is a pure function of a snapshot so the thresholds can be tested rather than
|
||||
// discovered in production.
|
||||
type Health struct {
|
||||
Level Level `json:"level"`
|
||||
Summary string `json:"summary"`
|
||||
Notes []Note `json:"notes"`
|
||||
Areas []string `json:"areas,omitempty"`
|
||||
}
|
||||
|
||||
type Level string
|
||||
|
||||
const (
|
||||
// LevelOK is nothing to do. LevelWatch is something that is not wrong yet and would
|
||||
// be if it continued — which is the whole reason trends are collected. LevelBad is
|
||||
// something an operator should act on now.
|
||||
LevelOK Level = "ok"
|
||||
LevelWatch Level = "watch"
|
||||
LevelBad Level = "bad"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
Level Level `json:"level"`
|
||||
// Area names the part of Memby to look at. It is the note's most useful field: a
|
||||
// number that has moved is only actionable once it points somewhere.
|
||||
Area string `json:"area"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// The thresholds. Heap is judged against the configured limit because that is the number
|
||||
// the container is actually killed at; the watch line is deliberately well below it, since
|
||||
// the point of a watch is to arrive before the incident rather than during it.
|
||||
const (
|
||||
heapShareBad = 0.90
|
||||
heapShareWatch = 0.75
|
||||
// goroutineLeakPerHour is what separates a gateway that has picked up work from one
|
||||
// that is not letting go of it. A household arriving home adds goroutines and gives
|
||||
// them back; a leak does not, so this is only ever consulted alongside a rising trend
|
||||
// measured over the trend window.
|
||||
goroutineLeakPerHour = 10
|
||||
goroutineFloodPerHour = 60
|
||||
// pauseWatchMs is a collection pause long enough for a television to notice. The
|
||||
// gateway answers a status poll every ten seconds from every open set.
|
||||
pauseWatchMs = 50
|
||||
pauseBadMs = 250
|
||||
// restartsWatch: a worker that has been launched this many times is one that keeps
|
||||
// dying. The registry counts starts for exactly this.
|
||||
restartsWatch = 3
|
||||
)
|
||||
|
||||
func assess(snapshot Snapshot) Health {
|
||||
var notes []Note
|
||||
|
||||
memory := snapshot.Memory
|
||||
switch {
|
||||
case memory.HeapShare >= heapShareBad:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelBad, Area: "Memory",
|
||||
Message: fmt.Sprintf(
|
||||
"The heap is using %s of the %s limit. Past the limit the container is stopped, so this needs attention now.",
|
||||
percent(memory.HeapShare), bytes(uint64(memory.MemoryLimit)),
|
||||
),
|
||||
})
|
||||
case memory.HeapShare >= heapShareWatch:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: "Memory",
|
||||
Message: fmt.Sprintf(
|
||||
"The heap is using %s of the %s limit. There is room, but less than usual.",
|
||||
percent(memory.HeapShare), bytes(uint64(memory.MemoryLimit)),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if snapshot.HeapTrend.Direction == DirectionRising {
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: "Memory",
|
||||
Message: fmt.Sprintf(
|
||||
"Heap in use has risen by about %s an hour over the last %s. If it does not fall after a collection, something is holding on to it.",
|
||||
bytes(uint64(math.Max(0, snapshot.HeapTrend.PerHour))), duration(snapshot.HeapTrend.SpanSeconds),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if snapshot.GoroutineTrend.Direction == DirectionRising {
|
||||
rate := snapshot.GoroutineTrend.PerHour
|
||||
level := LevelOK
|
||||
switch {
|
||||
case rate >= goroutineFloodPerHour:
|
||||
level = LevelBad
|
||||
case rate >= goroutineLeakPerHour:
|
||||
level = LevelWatch
|
||||
}
|
||||
if level != LevelOK {
|
||||
notes = append(notes, Note{
|
||||
Level: level, Area: "Goroutines",
|
||||
Message: fmt.Sprintf(
|
||||
"Goroutines have risen by about %.0f an hour over the last %s, from %.0f to %.0f. Open the breakdown to see which component they belong to.",
|
||||
rate, duration(snapshot.GoroutineTrend.SpanSeconds),
|
||||
snapshot.GoroutineTrend.First, snapshot.GoroutineTrend.Latest,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case memory.PauseRecentMs >= pauseBadMs:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelBad, Area: "Collection",
|
||||
Message: fmt.Sprintf(
|
||||
"Recent collections have paused the gateway for %.0f ms on average. Everything the televisions ask for waits that long.",
|
||||
memory.PauseRecentMs,
|
||||
),
|
||||
})
|
||||
case memory.PauseRecentMs >= pauseWatchMs:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: "Collection",
|
||||
Message: fmt.Sprintf(
|
||||
"Recent collections have paused the gateway for %.0f ms on average.", memory.PauseRecentMs,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
for _, worker := range snapshot.Workers {
|
||||
if worker.State == WorkerRunning && worker.Starts >= restartsWatch {
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: worker.Component,
|
||||
Message: fmt.Sprintf(
|
||||
"%s has been started %d times. A background worker that keeps restarting is failing at something.",
|
||||
worker.Name, worker.Starts,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Health{
|
||||
Level: worst(notes),
|
||||
Summary: summarise(snapshot, notes),
|
||||
Notes: notes,
|
||||
Areas: areasOf(notes),
|
||||
}
|
||||
}
|
||||
|
||||
func worst(notes []Note) Level {
|
||||
level := LevelOK
|
||||
for _, note := range notes {
|
||||
if note.Level == LevelBad {
|
||||
return LevelBad
|
||||
}
|
||||
if note.Level == LevelWatch {
|
||||
level = LevelWatch
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// summarise is the one line the overview card prints. When there is nothing to say it says
|
||||
// so plainly rather than reciting figures the operator can already see above it — and it
|
||||
// says whether the verdict is based on enough history to be worth anything, because "no
|
||||
// trouble" from four minutes of readings is a weaker claim than the same words from four
|
||||
// hours.
|
||||
func summarise(snapshot Snapshot, notes []Note) string {
|
||||
if len(notes) == 0 {
|
||||
if snapshot.GoroutineTrend.Direction == DirectionUnknown {
|
||||
return "Nothing abnormal. Still gathering history — trends need about fifteen minutes."
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Nothing abnormal. Goroutines and memory have been %s over the last %s.",
|
||||
steadyWord(snapshot), duration(snapshot.GoroutineTrend.SpanSeconds),
|
||||
)
|
||||
}
|
||||
areas := areasOf(notes)
|
||||
if len(notes) == 1 {
|
||||
return notes[0].Message
|
||||
}
|
||||
return fmt.Sprintf("%d things to look at, in %s.", len(notes), joinAreas(areas))
|
||||
}
|
||||
|
||||
func steadyWord(snapshot Snapshot) string {
|
||||
if snapshot.GoroutineTrend.Direction == DirectionFalling || snapshot.HeapTrend.Direction == DirectionFalling {
|
||||
return "steady or falling"
|
||||
}
|
||||
return "steady"
|
||||
}
|
||||
|
||||
func areasOf(notes []Note) []string {
|
||||
seen := map[string]bool{}
|
||||
var areas []string
|
||||
for _, note := range notes {
|
||||
if note.Area == "" || seen[note.Area] {
|
||||
continue
|
||||
}
|
||||
seen[note.Area] = true
|
||||
areas = append(areas, note.Area)
|
||||
}
|
||||
sort.Strings(areas)
|
||||
return areas
|
||||
}
|
||||
|
||||
func joinAreas(areas []string) string {
|
||||
switch len(areas) {
|
||||
case 0:
|
||||
return "the gateway"
|
||||
case 1:
|
||||
return areas[0]
|
||||
case 2:
|
||||
return areas[0] + " and " + areas[1]
|
||||
}
|
||||
return fmt.Sprintf("%s and %d other areas", areas[0], len(areas)-1)
|
||||
}
|
||||
|
||||
/* ---------- wording helpers ----------
|
||||
|
||||
These exist because the sentences above are the gateway's own wording, the stance every
|
||||
label the console prints takes: a console built before a threshold existed still reads
|
||||
correctly, and the figure in the sentence cannot disagree with the threshold that
|
||||
produced it. */
|
||||
|
||||
func percent(share float64) string {
|
||||
return fmt.Sprintf("%.1f%%", share*100)
|
||||
}
|
||||
|
||||
func bytes(value uint64) string {
|
||||
const unit = 1024
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
div, exponent := uint64(unit), 0
|
||||
for size := value / unit; size >= unit; size /= unit {
|
||||
div *= unit
|
||||
exponent++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(value)/float64(div), "KMGT"[exponent])
|
||||
}
|
||||
|
||||
func duration(seconds float64) string {
|
||||
switch {
|
||||
case seconds < 90:
|
||||
return fmt.Sprintf("%.0f seconds", seconds)
|
||||
case seconds < 5400:
|
||||
return fmt.Sprintf("%.0f minutes", seconds/60)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f hours", seconds/3600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Process is what the operating system knows about the container that the Go runtime does
|
||||
// not. Every field of it is optional: this is read from /proc, which exists in the
|
||||
// container the gateway is deployed in and does not exist on a developer's machine, so an
|
||||
// unavailable figure is omitted rather than reported as zero — nought open sockets and
|
||||
// "could not look" are different answers and the console must not print the first for the
|
||||
// second.
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
// CPUSeconds is cumulative processor time, and CPUPercent is the share of one
|
||||
// processor used since the previous read. A hundred per cent is one core saturated,
|
||||
// not the machine: with GOMAXPROCS processors available the ceiling is that times a
|
||||
// hundred, which is why the console prints both.
|
||||
CPUSeconds float64 `json:"cpuSeconds,omitempty"`
|
||||
CPUPercent float64 `json:"cpuPercent,omitempty"`
|
||||
CPUKnown bool `json:"cpuKnown"`
|
||||
OpenFiles int `json:"openFiles,omitempty"`
|
||||
OpenSockets int `json:"openSockets,omitempty"`
|
||||
FileLimit int `json:"fileLimit,omitempty"`
|
||||
FilesKnown bool `json:"filesKnown"`
|
||||
}
|
||||
|
||||
// threadCount is operating-system threads, which is a different number from goroutines and
|
||||
// is the one that matters when goroutines are blocked in syscalls: the runtime creates a
|
||||
// thread per blocked call, and a thread costs far more than a goroutine does.
|
||||
func threadCount() int {
|
||||
profile := pprof.Lookup("threadcreate")
|
||||
if profile == nil {
|
||||
return 0
|
||||
}
|
||||
return profile.Count()
|
||||
}
|
||||
|
||||
// configuredMemoryLimit reports what the container was started with rather than what the
|
||||
// runtime currently holds, so the console can say whether the limit is deliberate.
|
||||
func configuredMemoryLimit() string {
|
||||
return strings.TrimSpace(os.Getenv("GOMEMLIMIT"))
|
||||
}
|
||||
|
||||
var (
|
||||
cpuMu sync.Mutex
|
||||
lastCPUAt time.Time
|
||||
lastCPUSecs float64
|
||||
lastPercent float64
|
||||
)
|
||||
|
||||
func readProcess() Process {
|
||||
process := Process{PID: os.Getpid()}
|
||||
if seconds, ok := processCPUSeconds(); ok {
|
||||
process.CPUSeconds = seconds
|
||||
process.CPUKnown = true
|
||||
process.CPUPercent = cpuPercentSince(seconds, time.Now())
|
||||
}
|
||||
if files, sockets, ok := openDescriptors(); ok {
|
||||
process.OpenFiles = files
|
||||
process.OpenSockets = sockets
|
||||
process.FilesKnown = true
|
||||
process.FileLimit = descriptorLimit()
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
// cpuPercentSince needs two readings, so the first call after start-up reports nothing
|
||||
// rather than dividing by the life of the process — an average over four hours would hide
|
||||
// exactly the spike somebody opened the page to find. A read that arrives too soon after
|
||||
// the last one repeats the previous answer instead of amplifying rounding into a spike.
|
||||
func cpuPercentSince(seconds float64, now time.Time) float64 {
|
||||
const minInterval = 5 * time.Second
|
||||
cpuMu.Lock()
|
||||
defer cpuMu.Unlock()
|
||||
if lastCPUAt.IsZero() {
|
||||
lastCPUAt, lastCPUSecs = now, seconds
|
||||
return 0
|
||||
}
|
||||
elapsed := now.Sub(lastCPUAt)
|
||||
if elapsed < minInterval {
|
||||
return lastPercent
|
||||
}
|
||||
used := seconds - lastCPUSecs
|
||||
lastCPUAt, lastCPUSecs = now, seconds
|
||||
if used < 0 {
|
||||
lastPercent = 0
|
||||
return 0
|
||||
}
|
||||
lastPercent = used / elapsed.Seconds() * 100
|
||||
return lastPercent
|
||||
}
|
||||
|
||||
// processCPUSeconds prefers /proc/self/schedstat, whose first field is cumulative time on
|
||||
// a processor in nanoseconds — exact, where /proc/self/stat is in clock ticks whose length
|
||||
// cannot be read without cgo and has to be assumed to be the usual hundredth of a second.
|
||||
func processCPUSeconds() (float64, bool) {
|
||||
if raw, err := os.ReadFile("/proc/self/schedstat"); err == nil {
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) > 0 {
|
||||
if nanos, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
return nanos / 1e9, true
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("/proc/self/stat")
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
// The second field is the command name in brackets and may itself contain spaces, so
|
||||
// the fields after it are counted from the closing bracket rather than from the start.
|
||||
shut := strings.LastIndex(string(raw), ")")
|
||||
if shut < 0 {
|
||||
return 0, false
|
||||
}
|
||||
fields := strings.Fields(string(raw)[shut+1:])
|
||||
// After the bracket, field 1 is the state; utime and stime are fields 12 and 13.
|
||||
if len(fields) < 13 {
|
||||
return 0, false
|
||||
}
|
||||
user, userErr := strconv.ParseFloat(fields[11], 64)
|
||||
system, systemErr := strconv.ParseFloat(fields[12], 64)
|
||||
if userErr != nil || systemErr != nil {
|
||||
return 0, false
|
||||
}
|
||||
const ticksPerSecond = 100
|
||||
return (user + system) / ticksPerSecond, true
|
||||
}
|
||||
|
||||
func openDescriptors() (files, sockets int, ok bool) {
|
||||
entries, err := os.ReadDir("/proc/self/fd")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
files++
|
||||
target, err := os.Readlink("/proc/self/fd/" + entry.Name())
|
||||
if err == nil && strings.HasPrefix(target, "socket:") {
|
||||
sockets++
|
||||
}
|
||||
}
|
||||
return files, sockets, true
|
||||
}
|
||||
|
||||
func descriptorLimit() int {
|
||||
raw, err := os.ReadFile("/proc/self/limits")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if !strings.HasPrefix(line, "Max open files") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "Max open files"))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
limit, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return limit
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A real dump, trimmed. The parser is the one part of this package that reads text a Go
|
||||
// release produces, so the fixture is kept verbatim rather than idealised — the argument
|
||||
// lists, the "+0x" offsets and the "created by … in goroutine N" suffix are all things the
|
||||
// parser has to survive.
|
||||
const sampleDump = `goroutine 1 [chan receive, 940 minutes]:
|
||||
main.run(0xc000122000)
|
||||
/app/cmd/memby-server/main.go:430 +0x8f4
|
||||
main.main()
|
||||
/app/cmd/memby-server/main.go:41 +0x1f
|
||||
|
||||
goroutine 18 [select]:
|
||||
github.com/ponzischeme89/memby/server/internal/library.(*Ingester).Run(0xc0001a2000, {0x1a4c0d0, 0xc0000a2000})
|
||||
/app/internal/library/ingest.go:118 +0x145
|
||||
created by github.com/ponzischeme89/memby/server/internal/runtimestats.Go in goroutine 1
|
||||
|
||||
goroutine 40 [IO wait, 3 minutes]:
|
||||
internal/poll.runtime_pollWait(0x7f2c1c0, 0x72)
|
||||
/usr/local/go/src/runtime/netpoll.go:351 +0x85
|
||||
net/http.(*conn).serve(0xc000310000, {0x1a4c0d0, 0xc0003a0000})
|
||||
/usr/local/go/src/net/http/server.go:2092 +0x5db
|
||||
created by net/http.(*Server).Serve in goroutine 55
|
||||
|
||||
goroutine 41 [GC worker (idle)]:
|
||||
runtime.gopark(0x0, 0x0, 0x0, 0x0, 0x0)
|
||||
/usr/local/go/src/runtime/proc.go:402 +0xce
|
||||
runtime.gcBgMarkWorker(0xc0000581c0)
|
||||
/usr/local/go/src/runtime/mgc.go:1310 +0xe5
|
||||
created by runtime.gcBgMarkStartWorkers in goroutine 1
|
||||
|
||||
goroutine 77 [semacquire, 3 minutes, locked to thread]:
|
||||
github.com/jackc/puddle/v2.(*Pool).acquire(0xc00019c000)
|
||||
/root/go/pkg/mod/github.com/jackc/puddle/v2@v2.2.2/pool.go:481 +0x10a
|
||||
created by github.com/jackc/pgx/v5/pgxpool.NewWithConfig in goroutine 1
|
||||
`
|
||||
|
||||
func TestParseGoroutinesReadsHeaderStateAndFrames(t *testing.T) {
|
||||
list := parseGoroutines(sampleDump)
|
||||
if len(list) != 5 {
|
||||
t.Fatalf("parsed %d goroutines, want 5", len(list))
|
||||
}
|
||||
|
||||
first := list[0]
|
||||
if first.ID != 1 || first.State != "chan receive" || first.WaitMinutes != 940 {
|
||||
t.Fatalf("first = %+v", first)
|
||||
}
|
||||
if first.Frames[0].Function != "main.run" {
|
||||
t.Fatalf("first frame function = %q", first.Frames[0].Function)
|
||||
}
|
||||
// The offset is stripped so two goroutines in the same place group together rather
|
||||
// than differing by an address.
|
||||
if first.Frames[0].File != "/app/cmd/memby-server/main.go:430" {
|
||||
t.Fatalf("first frame file = %q", first.Frames[0].File)
|
||||
}
|
||||
|
||||
if list[1].CreatedBy == "" {
|
||||
t.Fatal("created-by line was dropped")
|
||||
}
|
||||
// A state with trailing detail after a second comma keeps its state and its wait.
|
||||
last := list[4]
|
||||
if last.State != "semacquire" || last.WaitMinutes != 3 {
|
||||
t.Fatalf("last = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// The categories have to partition the total or the breakdown is misleading, which is
|
||||
// worse than not offering one.
|
||||
func TestBuildReportPartitionsAndAttributes(t *testing.T) {
|
||||
report := buildReport(sampleDump, time.Now())
|
||||
if report.Total != 5 {
|
||||
t.Fatalf("total = %d", report.Total)
|
||||
}
|
||||
counted := 0
|
||||
for _, category := range report.Categories {
|
||||
counted += category.Count
|
||||
}
|
||||
if counted != report.Total {
|
||||
t.Fatalf("categories sum to %d, total %d", counted, report.Total)
|
||||
}
|
||||
attributed := 0
|
||||
for _, component := range report.Components {
|
||||
attributed += component.Count
|
||||
}
|
||||
if attributed != report.Total {
|
||||
t.Fatalf("components sum to %d, total %d", attributed, report.Total)
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
for _, group := range report.Groups {
|
||||
got[group.Component] = group.Function
|
||||
}
|
||||
// Memby's own packages win over the libraries they call, and the frame reported is
|
||||
// the one that names the work rather than the runtime plumbing every parked goroutine
|
||||
// shares.
|
||||
if got["Library sync"] != "github.com/ponzischeme89/memby/server/internal/library.(*Ingester).Run" {
|
||||
t.Fatalf("library group = %q", got["Library sync"])
|
||||
}
|
||||
if got["HTTP server"] != "net/http.(*conn).serve" {
|
||||
t.Fatalf("http group = %q", got["HTTP server"])
|
||||
}
|
||||
if got["Database pool"] != "github.com/jackc/puddle/v2.(*Pool).acquire" {
|
||||
t.Fatalf("database group = %q", got["Database pool"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryForKnownStates(t *testing.T) {
|
||||
cases := map[string]Category{
|
||||
"running": CategoryRunning,
|
||||
"runnable": CategoryRunning,
|
||||
"syscall": CategoryRunning,
|
||||
"IO wait": CategoryIO,
|
||||
"select": CategoryWaiting,
|
||||
"chan receive": CategoryWaiting,
|
||||
"semacquire": CategoryWaiting,
|
||||
"sync.Mutex.Lock": CategoryWaiting,
|
||||
"sleep": CategoryTimers,
|
||||
"timer goroutine": CategoryTimers,
|
||||
"GC worker (idle)": CategoryRuntime,
|
||||
"force gc (idle)": CategoryRuntime,
|
||||
"finalizer wait": CategoryRuntime,
|
||||
// The runtime's own wait outranks the "wait" rule, or the collector would be
|
||||
// reported as a component sitting on a channel.
|
||||
"wait for GC cycle": CategoryRuntime,
|
||||
"something new": CategoryOther,
|
||||
}
|
||||
for state, want := range cases {
|
||||
if got := categoryFor(state); got != want {
|
||||
t.Errorf("categoryFor(%q) = %q, want %q", state, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A live report has to hold together too: this is the one test that would notice a Go
|
||||
// release changing the dump format under the parser.
|
||||
func TestCollectGoroutinesAgreesWithTheRuntime(t *testing.T) {
|
||||
blocked := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(1)
|
||||
go func() {
|
||||
ready.Done()
|
||||
<-blocked
|
||||
}()
|
||||
ready.Wait()
|
||||
defer close(blocked)
|
||||
|
||||
report := CollectGoroutines()
|
||||
if report.Total < 2 {
|
||||
t.Fatalf("total = %d", report.Total)
|
||||
}
|
||||
counted := 0
|
||||
for _, category := range report.Categories {
|
||||
counted += category.Count
|
||||
}
|
||||
if counted != report.Total {
|
||||
t.Fatalf("categories sum to %d, total %d", counted, report.Total)
|
||||
}
|
||||
if report.CollectedInMs < 0 {
|
||||
t.Fatalf("collectedInMs = %v", report.CollectedInMs)
|
||||
}
|
||||
if !strings.Contains(StackDump(), "goroutine ") {
|
||||
t.Fatal("stack dump does not look like a stack dump")
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- trends ---------- */
|
||||
|
||||
func series(values []float64, every time.Duration) ([]float64, []time.Time) {
|
||||
at := make([]time.Time, len(values))
|
||||
base := time.Date(2026, 8, 19, 9, 0, 0, 0, time.UTC)
|
||||
for index := range values {
|
||||
at[index] = base.Add(time.Duration(index) * every)
|
||||
}
|
||||
return values, at
|
||||
}
|
||||
|
||||
func TestTrendRefusesToAnswerWithoutEnoughHistory(t *testing.T) {
|
||||
// Four readings a minute apart is a gateway that restarted three minutes ago, not a
|
||||
// trend, and "steady" would be a claim the data does not support.
|
||||
points, at := series([]float64{20, 24, 30, 44}, time.Minute)
|
||||
if got := trendOf(points, at, 5); got.Direction != DirectionUnknown {
|
||||
t.Fatalf("direction = %q, want unknown", got.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrendIgnoresABurstAndNamesASustainedRise(t *testing.T) {
|
||||
// A burst in the middle — a library import, a household arriving home — must not be
|
||||
// reported as a rise; medians are what make that true where a fitted line would not.
|
||||
burst := []float64{40, 41, 40, 42, 41, 300, 290, 40, 41, 40, 42, 41}
|
||||
points, at := series(burst, 5*time.Minute)
|
||||
if got := trendOf(points, at, 5); got.Direction != DirectionSteady {
|
||||
t.Fatalf("burst direction = %q (%+v), want steady", got.Direction, got)
|
||||
}
|
||||
|
||||
leak := make([]float64, 12)
|
||||
for index := range leak {
|
||||
leak[index] = float64(40 + index*5)
|
||||
}
|
||||
points, at = series(leak, 5*time.Minute)
|
||||
rising := trendOf(points, at, 5)
|
||||
if rising.Direction != DirectionRising {
|
||||
t.Fatalf("leak direction = %q (%+v), want rising", rising.Direction, rising)
|
||||
}
|
||||
if rising.PerHour <= 0 {
|
||||
t.Fatalf("perHour = %v, want a positive rate", rising.PerHour)
|
||||
}
|
||||
if rising.Latest != 95 || rising.First != 40 {
|
||||
t.Fatalf("first/latest = %v/%v", rising.First, rising.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrendFloorKeepsAQuietGatewaySteady(t *testing.T) {
|
||||
// One extra goroutine over an hour is not a leak, and without the absolute floor the
|
||||
// proportional threshold would call it one on a gateway holding a handful.
|
||||
points, at := series([]float64{8, 8, 9, 9, 9, 9, 9, 10}, 10*time.Minute)
|
||||
if got := trendOf(points, at, 5); got.Direction != DirectionSteady {
|
||||
t.Fatalf("direction = %q (%+v), want steady", got.Direction, got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the verdict ---------- */
|
||||
|
||||
func risingTrend(first, latest, perHour float64) Trend {
|
||||
return Trend{
|
||||
Direction: DirectionRising, PerHour: perHour, First: first, Latest: latest,
|
||||
SpanSeconds: 3600, Points: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessSaysNothingWhenThereIsNothingToSay(t *testing.T) {
|
||||
health := assess(Snapshot{
|
||||
Memory: Memory{MemoryLimit: 384 << 20, HeapInuse: 16 << 20, HeapShare: 0.04},
|
||||
GoroutineTrend: Trend{Direction: DirectionSteady, SpanSeconds: 7200},
|
||||
HeapTrend: Trend{Direction: DirectionSteady},
|
||||
})
|
||||
if health.Level != LevelOK || len(health.Notes) != 0 {
|
||||
t.Fatalf("health = %+v", health)
|
||||
}
|
||||
if !strings.Contains(health.Summary, "Nothing abnormal") {
|
||||
t.Fatalf("summary = %q", health.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessNamesTheAreaToInvestigate(t *testing.T) {
|
||||
health := assess(Snapshot{
|
||||
Memory: Memory{MemoryLimit: 384 << 20, HeapInuse: 350 << 20, HeapShare: 0.92},
|
||||
GoroutineTrend: risingTrend(60, 400, 340),
|
||||
HeapTrend: Trend{Direction: DirectionSteady},
|
||||
})
|
||||
if health.Level != LevelBad {
|
||||
t.Fatalf("level = %q", health.Level)
|
||||
}
|
||||
areas := strings.Join(health.Areas, ",")
|
||||
if !strings.Contains(areas, "Memory") || !strings.Contains(areas, "Goroutines") {
|
||||
t.Fatalf("areas = %v", health.Areas)
|
||||
}
|
||||
// A verdict with no area is the number that could not be acted on, which is the whole
|
||||
// defect this replaced.
|
||||
for _, note := range health.Notes {
|
||||
if note.Area == "" {
|
||||
t.Fatalf("note without an area: %+v", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessSeparatesADriftFromAFlood(t *testing.T) {
|
||||
drift := assess(Snapshot{GoroutineTrend: risingTrend(40, 60, 20)})
|
||||
if drift.Level != LevelWatch {
|
||||
t.Fatalf("drift level = %q", drift.Level)
|
||||
}
|
||||
flood := assess(Snapshot{GoroutineTrend: risingTrend(40, 400, 360)})
|
||||
if flood.Level != LevelBad {
|
||||
t.Fatalf("flood level = %q", flood.Level)
|
||||
}
|
||||
// A rise slower than the leak threshold is ordinary breathing and must not be a note
|
||||
// at all, or the card cries wolf on every busy evening.
|
||||
quiet := assess(Snapshot{GoroutineTrend: risingTrend(40, 44, 4)})
|
||||
if quiet.Level != LevelOK || len(quiet.Notes) != 0 {
|
||||
t.Fatalf("quiet = %+v", quiet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessNoticesAWorkerThatKeepsRestarting(t *testing.T) {
|
||||
health := assess(Snapshot{Workers: []Worker{
|
||||
{Name: "Library ingest", Component: "Library sync", State: WorkerRunning, Starts: 5},
|
||||
{Name: "Startup library sync", Component: "Library sync", State: WorkerFinished, Starts: 1},
|
||||
}})
|
||||
if health.Level != LevelWatch || len(health.Notes) != 1 {
|
||||
t.Fatalf("health = %+v", health)
|
||||
}
|
||||
if !strings.Contains(health.Notes[0].Message, "Library ingest") {
|
||||
t.Fatalf("note = %q", health.Notes[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the registry ---------- */
|
||||
|
||||
func TestWorkersRecordRunningAndFinished(t *testing.T) {
|
||||
resetWorkers()
|
||||
t.Cleanup(resetWorkers)
|
||||
|
||||
release := make(chan struct{})
|
||||
var running sync.WaitGroup
|
||||
running.Add(1)
|
||||
Go("Library ingest", "Library sync", func() {
|
||||
running.Done()
|
||||
<-release
|
||||
})
|
||||
running.Wait()
|
||||
|
||||
var done sync.WaitGroup
|
||||
done.Add(1)
|
||||
Go("Startup library sync", "Library sync", func() { done.Done() })
|
||||
done.Wait()
|
||||
// The finished worker's goroutine has run its body; give the deferred end() the moment
|
||||
// it needs to land before reading the registry.
|
||||
waitFor(t, func() bool { return stateOf(t, "Startup library sync") == WorkerFinished })
|
||||
|
||||
list := Workers()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("workers = %+v", list)
|
||||
}
|
||||
// Running first, so the table does not bury the thing that is still working under the
|
||||
// startup jobs that are not.
|
||||
if list[0].Name != "Library ingest" || list[0].State != WorkerRunning {
|
||||
t.Fatalf("first = %+v", list[0])
|
||||
}
|
||||
if list[1].State != WorkerFinished || list[1].Stopped.IsZero() {
|
||||
t.Fatalf("second = %+v", list[1])
|
||||
}
|
||||
close(release)
|
||||
waitFor(t, func() bool { return stateOf(t, "Library ingest") == WorkerFinished })
|
||||
|
||||
// A restart is counted rather than replacing the record: a worker restarted in a loop
|
||||
// reads exactly like a healthy one from a single snapshot and does not from Starts.
|
||||
Go("Library ingest", "Library sync", func() {})
|
||||
waitFor(t, func() bool { return startsOf(t, "Library ingest") == 2 })
|
||||
}
|
||||
|
||||
func stateOf(t *testing.T, name string) WorkerState {
|
||||
t.Helper()
|
||||
for _, worker := range Workers() {
|
||||
if worker.Name == name {
|
||||
return worker.State
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func startsOf(t *testing.T, name string) int {
|
||||
t.Helper()
|
||||
for _, worker := range Workers() {
|
||||
if worker.Name == name {
|
||||
return worker.Starts
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, done func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if done() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not reached")
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SampleInterval is how often the trend ring records a reading, and the ring's capacity is
|
||||
// how far back it can therefore see. A minute apart is far cheaper than the 30s poll the
|
||||
// console already makes, and four hours is long enough for a slow leak to become a line
|
||||
// rather than noise while costing a few kilobytes.
|
||||
const (
|
||||
SampleInterval = time.Minute
|
||||
trendCapacity = 240
|
||||
)
|
||||
|
||||
// Sample is one reading. Deliberately the same source as the headline figures — both come
|
||||
// from one ReadMemStats — because a trend drawn from a different counter than the number
|
||||
// printed above it is a trend an operator cannot check.
|
||||
type Sample struct {
|
||||
At time.Time `json:"at"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NumGC uint32 `json:"numGc"`
|
||||
}
|
||||
|
||||
var (
|
||||
samplesMu sync.Mutex
|
||||
samples []Sample
|
||||
started = time.Now()
|
||||
)
|
||||
|
||||
// StartSampling records a reading every SampleInterval until ctx ends. It takes the first
|
||||
// reading immediately, so a console opened a minute after a deployment has a baseline
|
||||
// rather than an empty chart.
|
||||
func StartSampling(ctx context.Context) {
|
||||
record(readSample())
|
||||
ticker := time.NewTicker(SampleInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
record(readSample())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readSample() Sample {
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
return Sample{
|
||||
At: time.Now(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
HeapInuse: memory.HeapInuse,
|
||||
Sys: memory.Sys,
|
||||
NumGC: memory.NumGC,
|
||||
}
|
||||
}
|
||||
|
||||
func record(sample Sample) {
|
||||
samplesMu.Lock()
|
||||
defer samplesMu.Unlock()
|
||||
samples = append(samples, sample)
|
||||
if len(samples) > trendCapacity {
|
||||
samples = append(samples[:0], samples[len(samples)-trendCapacity:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// Samples returns the trend ring oldest first.
|
||||
func Samples() []Sample {
|
||||
samplesMu.Lock()
|
||||
defer samplesMu.Unlock()
|
||||
out := make([]Sample, len(samples))
|
||||
copy(out, samples)
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- trends ---------- */
|
||||
|
||||
// Direction is a trend's verdict. "unknown" is a real answer and the common one for the
|
||||
// first few minutes after a restart: claiming "steady" from two readings a minute apart
|
||||
// would be a claim the data does not support.
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
DirectionUnknown Direction = "unknown"
|
||||
DirectionSteady Direction = "steady"
|
||||
DirectionRising Direction = "rising"
|
||||
DirectionFalling Direction = "falling"
|
||||
)
|
||||
|
||||
type Trend struct {
|
||||
Direction Direction `json:"direction"`
|
||||
// PerHour is the observed rate of change in the value's own units. It is what makes
|
||||
// "rising" actionable: two goroutines an hour is a leak worth naming, two a minute is
|
||||
// one worth investigating tonight.
|
||||
PerHour float64 `json:"perHour"`
|
||||
First float64 `json:"first"`
|
||||
Latest float64 `json:"latest"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
SpanSeconds float64 `json:"spanSeconds"`
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// minTrendPoints and minTrendSpan are what a trend has to earn before it is stated at all.
|
||||
// A leak is a claim about a slope, and a slope read off three readings inside five minutes
|
||||
// is indistinguishable from a household that happened to start watching something.
|
||||
const (
|
||||
minTrendPoints = 5
|
||||
minTrendSpan = 15 * time.Minute
|
||||
)
|
||||
|
||||
// trendOf is the whole rule and it is pure. It compares the median of the oldest quarter
|
||||
// of the window with the median of the newest quarter rather than fitting a line, because
|
||||
// a single burst — a library import, a household arriving home — is exactly the shape
|
||||
// least-squares reports as a trend and medians ignore.
|
||||
//
|
||||
// The threshold is proportional with an absolute floor: without the proportion a busy
|
||||
// gateway is permanently "rising", and without the floor a quiet one calls one extra
|
||||
// goroutine a trend.
|
||||
func trendOf(points []float64, at []time.Time, floor float64) Trend {
|
||||
trend := Trend{Direction: DirectionUnknown, Points: len(points)}
|
||||
if len(points) == 0 || len(at) != len(points) {
|
||||
return trend
|
||||
}
|
||||
trend.First, trend.Latest = points[0], points[len(points)-1]
|
||||
trend.Min, trend.Max = points[0], points[0]
|
||||
for _, value := range points {
|
||||
trend.Min = math.Min(trend.Min, value)
|
||||
trend.Max = math.Max(trend.Max, value)
|
||||
}
|
||||
span := at[len(at)-1].Sub(at[0])
|
||||
trend.SpanSeconds = span.Seconds()
|
||||
if len(points) < minTrendPoints || span < minTrendSpan {
|
||||
return trend
|
||||
}
|
||||
|
||||
quarter := len(points) / 4
|
||||
if quarter < 1 {
|
||||
quarter = 1
|
||||
}
|
||||
early := median(points[:quarter])
|
||||
late := median(points[len(points)-quarter:])
|
||||
change := late - early
|
||||
trend.PerHour = change / span.Hours()
|
||||
|
||||
threshold := math.Max(floor, early*0.15)
|
||||
switch {
|
||||
case change > threshold:
|
||||
trend.Direction = DirectionRising
|
||||
case change < -threshold:
|
||||
trend.Direction = DirectionFalling
|
||||
default:
|
||||
trend.Direction = DirectionSteady
|
||||
}
|
||||
return trend
|
||||
}
|
||||
|
||||
func median(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]float64, len(values))
|
||||
copy(sorted, values)
|
||||
sort.Float64s(sorted)
|
||||
middle := len(sorted) / 2
|
||||
if len(sorted)%2 == 1 {
|
||||
return sorted[middle]
|
||||
}
|
||||
return (sorted[middle-1] + sorted[middle]) / 2
|
||||
}
|
||||
|
||||
func trendsFrom(list []Sample) (goroutines, heap, reserved Trend) {
|
||||
at := make([]time.Time, len(list))
|
||||
goroutinePoints := make([]float64, len(list))
|
||||
heapPoints := make([]float64, len(list))
|
||||
reservedPoints := make([]float64, len(list))
|
||||
for index, sample := range list {
|
||||
at[index] = sample.At
|
||||
goroutinePoints[index] = float64(sample.Goroutines)
|
||||
heapPoints[index] = float64(sample.HeapInuse)
|
||||
reservedPoints[index] = float64(sample.Sys)
|
||||
}
|
||||
// The floors: five goroutines, and four megabytes of memory. Below those, movement is
|
||||
// the ordinary breathing of a server answering requests.
|
||||
return trendOf(goroutinePoints, at, 5),
|
||||
trendOf(heapPoints, at, 4<<20),
|
||||
trendOf(reservedPoints, at, 4<<20)
|
||||
}
|
||||
|
||||
/* ---------- the snapshot the console polls ---------- */
|
||||
|
||||
// Memory separates the three figures an operator keeps conflating: what the program is
|
||||
// actually holding, what the Go runtime has reserved from the operating system on its
|
||||
// behalf, and the ceiling it has been given. Reserved is always the largest and is not a
|
||||
// leak; the ratio to the limit is the number worth watching.
|
||||
type Memory struct {
|
||||
HeapAlloc uint64 `json:"heapAlloc"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
HeapIdle uint64 `json:"heapIdle"`
|
||||
HeapReleased uint64 `json:"heapReleased"`
|
||||
StackInuse uint64 `json:"stackInuse"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NextGC uint64 `json:"nextGc"`
|
||||
NumGC uint32 `json:"numGc"`
|
||||
// PauseTotalMs and PauseRecentMs are how much of the life of the gateway has been
|
||||
// spent stopped for collection. A rising heap that is being collected without pausing
|
||||
// is a working cache; one that pauses for longer every cycle is not.
|
||||
PauseTotalMs float64 `json:"pauseTotalMs"`
|
||||
PauseRecentMs float64 `json:"pauseRecentMs"`
|
||||
MemoryLimit int64 `json:"memoryLimit"`
|
||||
ConfiguredLim string `json:"configuredLimit,omitempty"`
|
||||
// HeapShare is heap in use as a fraction of the limit, or 0 when there is no limit.
|
||||
// Computed here rather than in the console so the health verdict and the figure the
|
||||
// operator reads cannot disagree.
|
||||
HeapShare float64 `json:"heapShare"`
|
||||
// GCPerHour is measured over the trend window rather than over the whole life of the
|
||||
// process, because "collections have become more frequent" is the useful form of it.
|
||||
GCPerHour float64 `json:"gcPerHour"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
At time.Time `json:"at"`
|
||||
UptimeSeconds float64 `json:"uptimeSeconds"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
GOMAXPROCS int `json:"gomaxprocs"`
|
||||
Threads int `json:"threads"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
|
||||
Memory Memory `json:"memory"`
|
||||
Workers []Worker `json:"workers"`
|
||||
Process Process `json:"process"`
|
||||
|
||||
Samples []Sample `json:"samples"`
|
||||
GoroutineTrend Trend `json:"goroutineTrend"`
|
||||
HeapTrend Trend `json:"heapTrend"`
|
||||
ReservedTrend Trend `json:"reservedTrend"`
|
||||
SampleEverySecs float64 `json:"sampleEverySeconds"`
|
||||
|
||||
Health Health `json:"health"`
|
||||
}
|
||||
|
||||
// Read builds the cheap snapshot. Nothing in here walks a stack or stops the world for
|
||||
// longer than a ReadMemStats, so it is safe on the 30-second poll every open console tab
|
||||
// makes.
|
||||
func Read() Snapshot {
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
now := time.Now()
|
||||
|
||||
limit := debug.SetMemoryLimit(-1)
|
||||
share := 0.0
|
||||
if limit > 0 && limit < math.MaxInt64 {
|
||||
share = float64(memory.HeapInuse) / float64(limit)
|
||||
}
|
||||
|
||||
list := Samples()
|
||||
goroutineTrend, heapTrend, reservedTrend := trendsFrom(list)
|
||||
|
||||
snapshot := Snapshot{
|
||||
At: now,
|
||||
UptimeSeconds: now.Sub(started).Seconds(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
GOMAXPROCS: runtime.GOMAXPROCS(0),
|
||||
Threads: threadCount(),
|
||||
GoVersion: runtime.Version(),
|
||||
Memory: Memory{
|
||||
HeapAlloc: memory.HeapAlloc,
|
||||
HeapInuse: memory.HeapInuse,
|
||||
HeapIdle: memory.HeapIdle,
|
||||
HeapReleased: memory.HeapReleased,
|
||||
StackInuse: memory.StackInuse,
|
||||
Sys: memory.Sys,
|
||||
NextGC: memory.NextGC,
|
||||
NumGC: memory.NumGC,
|
||||
PauseTotalMs: float64(memory.PauseTotalNs) / 1e6,
|
||||
PauseRecentMs: recentPauseMs(&memory),
|
||||
MemoryLimit: limit,
|
||||
ConfiguredLim: configuredMemoryLimit(),
|
||||
HeapShare: share,
|
||||
GCPerHour: gcPerHour(list),
|
||||
},
|
||||
Workers: Workers(),
|
||||
Process: readProcess(),
|
||||
Samples: list,
|
||||
GoroutineTrend: goroutineTrend,
|
||||
HeapTrend: heapTrend,
|
||||
ReservedTrend: reservedTrend,
|
||||
SampleEverySecs: SampleInterval.Seconds(),
|
||||
}
|
||||
snapshot.Health = assess(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// recentPauseMs averages the last few recorded pauses. Go keeps a circular buffer of the
|
||||
// most recent 256, and the running total on its own cannot distinguish a process that
|
||||
// paused badly an hour ago from one pausing badly now.
|
||||
func recentPauseMs(memory *runtime.MemStats) float64 {
|
||||
const window = 16
|
||||
if memory.NumGC == 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
total := uint64(0)
|
||||
for index := 0; index < window; index++ {
|
||||
slot := int(memory.NumGC) - 1 - index
|
||||
if slot < 0 {
|
||||
break
|
||||
}
|
||||
total += memory.PauseNs[slot%256]
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total) / float64(count) / 1e6
|
||||
}
|
||||
|
||||
func gcPerHour(list []Sample) float64 {
|
||||
if len(list) < 2 {
|
||||
return 0
|
||||
}
|
||||
span := list[len(list)-1].At.Sub(list[0].At).Hours()
|
||||
if span <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(list[len(list)-1].NumGC-list[0].NumGC) / span
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package runtimestats answers the one question the console's Process card exists for: is
|
||||
// the gateway healthy, and if something is abnormal, which area should be looked at?
|
||||
//
|
||||
// A bare goroutine count cannot answer it. Twenty-five means nothing on its own — an
|
||||
// operator cannot tell from it what those goroutines are doing, which part of Memby they
|
||||
// belong to, whether the number is normal, or whether it has been climbing all week. This
|
||||
// package supplies the three things that make the number readable, and keeps them apart by
|
||||
// what they cost:
|
||||
//
|
||||
// - The registry here, which is free. A long-running Memby worker says its own name when
|
||||
// it starts, so "Library ingest" and "Emby health probe" are named rather than inferred
|
||||
// from a stack.
|
||||
// - The sampler in sample.go, which is a handful of counters on a slow tick. A single
|
||||
// instantaneous figure cannot show a leak; a trend can.
|
||||
// - The stack breakdown in goroutines.go, which is genuinely expensive and is therefore
|
||||
// collected only when an operator asks for it.
|
||||
//
|
||||
// Nothing here recovers a panic. A worker that dies must die exactly as it always did —
|
||||
// this package reports, and reporting must never change what it is reporting on.
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkerState is what became of a tracked worker. A worker that ends is not a fault —
|
||||
// several of the gateway's background jobs are one-shot startup work — but "still running"
|
||||
// and "finished" are different answers and the console must not merge them.
|
||||
type WorkerState string
|
||||
|
||||
const (
|
||||
WorkerRunning WorkerState = "running"
|
||||
WorkerFinished WorkerState = "finished"
|
||||
)
|
||||
|
||||
// Worker is one named background goroutine.
|
||||
type Worker struct {
|
||||
Name string `json:"name"`
|
||||
Component string `json:"component"`
|
||||
State WorkerState `json:"state"`
|
||||
Started time.Time `json:"started"`
|
||||
Stopped time.Time `json:"stopped,omitempty"`
|
||||
// Starts counts how many times this name has been launched. It is on the record
|
||||
// because a worker that is being restarted in a loop reads exactly like a healthy one
|
||||
// from a single snapshot, and does not from this number.
|
||||
Starts int `json:"starts"`
|
||||
}
|
||||
|
||||
type workerEntry struct {
|
||||
component string
|
||||
running int
|
||||
starts int
|
||||
started time.Time
|
||||
stopped time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
workersMu sync.Mutex
|
||||
workers = map[string]*workerEntry{}
|
||||
)
|
||||
|
||||
// Go starts fn on its own goroutine and records it under a name an operator can read. The
|
||||
// name is the worker's identity across restarts, so it must be stable and must not carry a
|
||||
// count or an address in it.
|
||||
func Go(name, component string, fn func()) {
|
||||
begin(name, component)
|
||||
go func() {
|
||||
defer end(name)
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// Track records a worker whose goroutine something else owns. The returned function marks
|
||||
// it finished, and is safe to call more than once.
|
||||
func Track(name, component string) (done func()) {
|
||||
begin(name, component)
|
||||
var once sync.Once
|
||||
return func() { once.Do(func() { end(name) }) }
|
||||
}
|
||||
|
||||
func begin(name, component string) {
|
||||
now := time.Now()
|
||||
workersMu.Lock()
|
||||
defer workersMu.Unlock()
|
||||
entry := workers[name]
|
||||
if entry == nil {
|
||||
entry = &workerEntry{}
|
||||
workers[name] = entry
|
||||
}
|
||||
entry.component = component
|
||||
entry.running++
|
||||
entry.starts++
|
||||
entry.started = now
|
||||
entry.stopped = time.Time{}
|
||||
}
|
||||
|
||||
func end(name string) {
|
||||
now := time.Now()
|
||||
workersMu.Lock()
|
||||
defer workersMu.Unlock()
|
||||
entry := workers[name]
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
if entry.running > 0 {
|
||||
entry.running--
|
||||
}
|
||||
if entry.running == 0 {
|
||||
entry.stopped = now
|
||||
}
|
||||
}
|
||||
|
||||
// Workers lists what has been registered, running first and then by name, so the table
|
||||
// does not reorder itself under the operator on every poll.
|
||||
func Workers() []Worker {
|
||||
workersMu.Lock()
|
||||
list := make([]Worker, 0, len(workers))
|
||||
for name, entry := range workers {
|
||||
worker := Worker{
|
||||
Name: name,
|
||||
Component: entry.component,
|
||||
State: WorkerFinished,
|
||||
Started: entry.started,
|
||||
Stopped: entry.stopped,
|
||||
Starts: entry.starts,
|
||||
}
|
||||
if entry.running > 0 {
|
||||
worker.State = WorkerRunning
|
||||
worker.Stopped = time.Time{}
|
||||
}
|
||||
list = append(list, worker)
|
||||
}
|
||||
workersMu.Unlock()
|
||||
sort.Slice(list, func(a, b int) bool {
|
||||
if (list[a].State == WorkerRunning) != (list[b].State == WorkerRunning) {
|
||||
return list[a].State == WorkerRunning
|
||||
}
|
||||
return list[a].Name < list[b].Name
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// resetWorkers exists for the tests; the registry is process-wide by design.
|
||||
func resetWorkers() {
|
||||
workersMu.Lock()
|
||||
workers = map[string]*workerEntry{}
|
||||
workersMu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user